diff --git a/.eslintrc.js b/.eslintrc.js index b70090a50e64d..ab868c29b7bed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -89,6 +89,72 @@ const SAFER_LODASH_SET_DEFINITELYTYPED_HEADER = ` */ `; +/** Packages which should not be included within production code. */ +const DEV_PACKAGES = [ + 'kbn-babel-code-parser', + 'kbn-dev-utils', + 'kbn-docs-utils', + 'kbn-es*', + 'kbn-eslint*', + 'kbn-optimizer', + 'kbn-plugin-generator', + 'kbn-plugin-helpers', + 'kbn-pm', + 'kbn-storybook', + 'kbn-telemetry-tools', + 'kbn-test', +]; + +/** Directories (at any depth) which include dev-only code. */ +const DEV_DIRECTORIES = [ + '.storybook', + '__tests__', + '__test__', + '__jest__', + '__fixtures__', + '__mocks__', + '__stories__', + 'e2e', + 'fixtures', + 'ftr_e2e', + 'integration_tests', + 'manual_tests', + 'mock', + 'storybook', + 'scripts', + 'test', + 'test-d', + 'test_utils', + 'test_utilities', + 'test_helpers', + 'tests_client_integration', +]; + +/** File patterns for dev-only code. */ +const DEV_FILE_PATTERNS = [ + '*.mock.{js,ts,tsx}', + '*.test.{js,ts,tsx}', + '*.test.helpers.{js,ts,tsx}', + '*.stories.{js,ts,tsx}', + '*.story.{js,ts,tsx}', + '*.stub.{js,ts,tsx}', + 'mock.{js,ts,tsx}', + '_stubs.{js,ts,tsx}', + '{testHelpers,test_helper,test_utils}.{js,ts,tsx}', + '{postcss,webpack}.config.js', +]; + +/** Glob patterns which describe dev-only code. */ +const DEV_PATTERNS = [ + ...DEV_PACKAGES.map((pkg) => `packages/${pkg}/**/*`), + ...DEV_DIRECTORIES.map((dir) => `{packages,src,x-pack}/**/${dir}/**/*`), + ...DEV_FILE_PATTERNS.map((file) => `{packages,src,x-pack}/**/${file}`), + 'packages/kbn-interpreter/tasks/**/*', + 'src/dev/**/*', + 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*', + 'x-pack/plugins/*/server/scripts/**/*', +]; + module.exports = { root: true, @@ -491,43 +557,17 @@ module.exports = { }, /** - * Files that ARE NOT allowed to use devDependencies - */ - { - files: ['x-pack/**/*.js', 'packages/kbn-interpreter/**/*.js'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - devDependencies: false, - peerDependencies: true, - packageDir: '.', - }, - ], - }, - }, - - /** - * Files that ARE allowed to use devDependencies + * Single package.json rules, it tells eslint to ignore the child package.json files + * and look for dependencies declarations in the single and root level package.json */ { - files: [ - 'packages/kbn-es/src/**/*.js', - 'packages/kbn-interpreter/tasks/**/*.js', - 'packages/kbn-interpreter/src/plugin/**/*.js', - 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*.js', - 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__,public}/**/*.js', - 'x-pack/**/*.test.js', - 'x-pack/test_utils/**/*', - 'x-pack/gulpfile.js', - 'x-pack/plugins/apm/public/utils/testHelpers.js', - 'x-pack/plugins/canvas/shareable_runtime/postcss.config.js', - ], + files: ['{src,x-pack,packages}/**/*.{js,mjs,ts,tsx}'], rules: { 'import/no-extraneous-dependencies': [ 'error', { - devDependencies: true, + /* Files that ARE allowed to use devDependencies */ + devDependencies: [...DEV_PATTERNS], peerDependencies: true, packageDir: '.', }, @@ -1420,21 +1460,5 @@ module.exports = { ], }, }, - - /** - * Single package.json rules, it tells eslint to ignore the child package.json files - * and look for dependencies declarations in the single and root level package.json - */ - { - files: ['**/*.{js,mjs,ts,tsx}'], - rules: { - 'import/no-extraneous-dependencies': [ - 'error', - { - packageDir: '.', - }, - ], - }, - }, ], }; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f2f260addb35..33b3e4a7dede6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,6 +59,7 @@ /x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-services /x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-services /x-pack/plugins/runtime_fields @elastic/kibana-app-services +/x-pack/test/search_sessions_integration/ @elastic/kibana-app-services #CC# /src/plugins/bfetch/ @elastic/kibana-app-services #CC# /src/plugins/index_pattern_management/ @elastic/kibana-app-services #CC# /src/plugins/inspector/ @elastic/kibana-app-services diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index aa68db29974a8..348d756c141b0 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Question url: https://discuss.elastic.co/c/kibana diff --git a/docs/api/actions-and-connectors.asciidoc b/docs/api/actions-and-connectors.asciidoc index 5480cdd57f691..ff4cb8401091e 100644 --- a/docs/api/actions-and-connectors.asciidoc +++ b/docs/api/actions-and-connectors.asciidoc @@ -5,19 +5,19 @@ Manage Actions and Connectors. The following connector APIs are available: -* <> to retrieve a single connector by ID +* <> to retrieve a single connector by ID -* <> to retrieve all connectors +* <> to retrieve all connectors -* <> to retrieve a list of all connector types +* <> to retrieve a list of all connector types -* <> to create connectors +* <> to create connectors -* <> to update the attributes for an existing connector +* <> to update the attributes for an existing connector -* <> to execute a connector by ID +* <> to execute a connector by ID -* <> to delete a connector by ID +* <> to delete a connector by ID For deprecated APIs, refer to <>. diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index c9a09e890ea6d..554e84615d568 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -1,25 +1,25 @@ -[[actions-and-connectors-api-create]] +[[create-connector-api]] === Create connector API ++++ -Create connector API +Create connector ++++ Creates a connector. -[[actions-and-connectors-api-create-request]] +[[create-connector-api-request]] ==== Request `POST :/api/actions/connector` `POST :/s//api/actions/connector` -[[actions-and-connectors-api-create-path-params]] +[[create-connector-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-create-request-body]] +[[create-connector-api-request-body]] ==== Request body `name`:: @@ -36,15 +36,15 @@ Creates a connector. (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. +WARNING: Remember these values. You must provide them each time you call the <> API. -[[actions-and-connectors-api-create-request-codes]] +[[create-connector-api-request-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-create-example]] +[[create-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/delete.asciidoc b/docs/api/actions-and-connectors/delete.asciidoc index a9f9e658613e0..021a3f7cdf3f7 100644 --- a/docs/api/actions-and-connectors/delete.asciidoc +++ b/docs/api/actions-and-connectors/delete.asciidoc @@ -1,21 +1,21 @@ -[[actions-and-connectors-api-delete]] +[[delete-connector-api]] === Delete connector API ++++ -Delete connector API +Delete connector ++++ Deletes an connector by ID. WARNING: When you delete a connector, _it cannot be recovered_. -[[actions-and-connectors-api-delete-request]] +[[delete-connector-api-request]] ==== Request `DELETE :/api/actions/connector/` `DELETE :/s//api/actions/connector/` -[[actions-and-connectors-api-delete-path-params]] +[[delete-connector-api-path-params]] ==== Path parameters `id`:: @@ -24,7 +24,7 @@ WARNING: When you delete a connector, _it cannot be recovered_. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-delete-response-codes]] +[[delete-connector-api-response-codes]] ==== Response code `200`:: diff --git a/docs/api/actions-and-connectors/execute.asciidoc b/docs/api/actions-and-connectors/execute.asciidoc index b87380907f7bb..e830c9b4bbf88 100644 --- a/docs/api/actions-and-connectors/execute.asciidoc +++ b/docs/api/actions-and-connectors/execute.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-execute]] +[[execute-connector-api]] === Execute connector API ++++ -Execute connector API +Execute connector ++++ Executes a connector by ID. -[[actions-and-connectors-api-execute-request]] +[[execute-connector-api-request]] ==== Request `POST :/api/actions/connector//_execute` `POST :/s//api/actions/connector//_execute` -[[actions-and-connectors-api-execute-params]] +[[execute-connector-api-params]] ==== Path parameters `id`:: @@ -22,20 +22,20 @@ Executes a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-execute-request-body]] +[[execute-connector-api-request-body]] ==== Request body `params`:: (Required, object) The parameters of the connector. Parameter properties vary depending on the connector type. For information about the parameter properties, refer to <>. -[[actions-and-connectors-api-execute-codes]] +[[execute-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-execute-example]] +[[execute-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index 33d37a4add4dd..0d9af45c4ef0c 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-get]] +[[get-connector-api]] === Get connector API ++++ -Get connector API +Get connector ++++ Retrieves a connector by ID. -[[actions-and-connectors-api-get-request]] +[[get-connector-api-request]] ==== Request `GET :/api/actions/connector/` `GET :/s//api/actions/connector/` -[[actions-and-connectors-api-get-params]] +[[get-connector-api-params]] ==== Path parameters `id`:: @@ -22,13 +22,13 @@ Retrieves a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-codes]] +[[get-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-example]] +[[get-connector-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index 8b4977d61e741..e4e67a9bbde73 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-get-all]] -=== Get all actions API +[[get-all-connectors-api]] +=== Get all connectors API ++++ -Get all actions API +Get all connectors ++++ Retrieves all connectors. -[[actions-and-connectors-api-get-all-request]] +[[get-all-connectors-api-request]] ==== Request `GET :/api/actions/connectors` `GET :/s//api/actions/connectors` -[[actions-and-connectors-api-get-all-path-params]] +[[get-all-connectors-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-get-all-codes]] +[[get-all-connectors-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-get-all-example]] +[[get-all-connectors-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/legacy/create.asciidoc b/docs/api/actions-and-connectors/legacy/create.asciidoc index faf6227f01947..af4feddcb80fb 100644 --- a/docs/api/actions-and-connectors/legacy/create.asciidoc +++ b/docs/api/actions-and-connectors/legacy/create.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-create]] ==== Legacy Create connector API ++++ -Legacy Create connector API +Legacy Create connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Creates a connector. diff --git a/docs/api/actions-and-connectors/legacy/delete.asciidoc b/docs/api/actions-and-connectors/legacy/delete.asciidoc index b02f1011fd9b4..170fceba2d157 100644 --- a/docs/api/actions-and-connectors/legacy/delete.asciidoc +++ b/docs/api/actions-and-connectors/legacy/delete.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-delete]] ==== Legacy Delete connector API ++++ -Legacy Delete connector API +Legacy Delete connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Deletes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/execute.asciidoc b/docs/api/actions-and-connectors/legacy/execute.asciidoc index 30cb18c54aa69..200844ab72f17 100644 --- a/docs/api/actions-and-connectors/legacy/execute.asciidoc +++ b/docs/api/actions-and-connectors/legacy/execute.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-execute]] ==== Legacy Execute connector API ++++ -Legacy Execute connector API +Legacy Execute connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Executes a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get.asciidoc b/docs/api/actions-and-connectors/legacy/get.asciidoc index cf8cc1b6b677e..1b138fb7032e0 100644 --- a/docs/api/actions-and-connectors/legacy/get.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get]] ==== Legacy Get connector API ++++ -Legacy Get connector API +Legacy Get connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a connector by ID. diff --git a/docs/api/actions-and-connectors/legacy/get_all.asciidoc b/docs/api/actions-and-connectors/legacy/get_all.asciidoc index 24ad446d95d95..ba235955c005e 100644 --- a/docs/api/actions-and-connectors/legacy/get_all.asciidoc +++ b/docs/api/actions-and-connectors/legacy/get_all.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-get-all]] ==== Legacy Get all connector API ++++ -Legacy Get all connector API +Legacy Get all connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves all connectors. diff --git a/docs/api/actions-and-connectors/legacy/list.asciidoc b/docs/api/actions-and-connectors/legacy/list.asciidoc index 86026f332d917..8acfd5415af57 100644 --- a/docs/api/actions-and-connectors/legacy/list.asciidoc +++ b/docs/api/actions-and-connectors/legacy/list.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-list]] ==== Legacy List connector types API ++++ -Legacy List all connector types API +Legacy List all connector types ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Retrieves a list of all connector types. diff --git a/docs/api/actions-and-connectors/legacy/update.asciidoc b/docs/api/actions-and-connectors/legacy/update.asciidoc index c2e841988717a..517daf9a40dca 100644 --- a/docs/api/actions-and-connectors/legacy/update.asciidoc +++ b/docs/api/actions-and-connectors/legacy/update.asciidoc @@ -1,12 +1,10 @@ [[actions-and-connectors-legacy-api-update]] ==== Legacy Update connector API ++++ -Legacy Update connector API +Legacy Update connector ++++ -deprecated::[7.13.0] - -Please use the <> instead. +WARNING: Deprecated in 7.13.0. Use <> instead. Updates the attributes for an existing connector. diff --git a/docs/api/actions-and-connectors/list.asciidoc b/docs/api/actions-and-connectors/list.asciidoc index 941f7b4376e91..bd1ccb777b9ae 100644 --- a/docs/api/actions-and-connectors/list.asciidoc +++ b/docs/api/actions-and-connectors/list.asciidoc @@ -1,31 +1,31 @@ -[[actions-and-connectors-api-list]] +[[list-connector-types-api]] === List connector types API ++++ -List all connector types API +List all connector types ++++ Retrieves a list of all connector types. -[[actions-and-connectors-api-list-request]] +[[list-connector-types-api-request]] ==== Request `GET :/api/actions/connector_types` `GET :/s//api/actions/connector_types` -[[actions-and-connectors-api-list-path-params]] +[[list-connector-types-api-path-params]] ==== Path parameters `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-list-codes]] +[[list-connector-types-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-list-example]] +[[list-connector-types-api-example]] ==== Example [source,sh] diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc index 6c4e6040bdfb5..f522cb8d048e0 100644 --- a/docs/api/actions-and-connectors/update.asciidoc +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -1,19 +1,19 @@ -[[actions-and-connectors-api-update]] +[[update-connector-api]] === Update connector API ++++ -Update connector API +Update connector ++++ Updates the attributes for an existing connector. -[[actions-and-connectors-api-update-request]] +[[update-connector-api-request]] ==== Request `PUT :/api/actions/connector/` `PUT :/s//api/actions/connector/` -[[actions-and-connectors-api-update-params]] +[[update-connector-api-params]] ==== Path parameters `id`:: @@ -22,7 +22,7 @@ Updates the attributes for an existing connector. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. -[[actions-and-connectors-api-update-request-body]] +[[update-connector-api-request-body]] ==== Request body `name`:: @@ -34,13 +34,13 @@ Updates the attributes for an existing connector. `secrets`:: (Required, object) The updated secrets configuration for the connector. Secrets properties vary depending on the connector type. For information about the secrets configuration properties, refer to <>. -[[actions-and-connectors-api-update-codes]] +[[update-connector-api-codes]] ==== Response code `200`:: Indicates a successful call. -[[actions-and-connectors-api-update-example]] +[[update-connector-api-example]] ==== Example [source,sh] diff --git a/docs/apm/service-overview.asciidoc b/docs/apm/service-overview.asciidoc index 36d021d64456e..693046d652943 100644 --- a/docs/apm/service-overview.asciidoc +++ b/docs/apm/service-overview.asciidoc @@ -38,6 +38,8 @@ image::apm/images/traffic-transactions.png[Traffic and transactions] === Error rate and errors The *Error rate* chart displays the average error rates relating to the service, within a specific time range. +An HTTP response code greater than 400 does not necessarily indicate a failed transaction. +<>. The *Errors* table provides a high-level view of each error message when it first and last occurred, along with the total number of occurrences. This makes it very easy to quickly see which errors affect diff --git a/docs/apm/transactions.asciidoc b/docs/apm/transactions.asciidoc index 8c8da81aa577e..c2a3e0bc2502d 100644 --- a/docs/apm/transactions.asciidoc +++ b/docs/apm/transactions.asciidoc @@ -22,11 +22,21 @@ Visualize response codes: `2xx`, `3xx`, `4xx`, etc. Useful for determining if more responses than usual are being served with a particular response code. Like in the latency graph, you can zoom in on anomalies to further investigate them. +[[transaction-error-rate]] *Error rate*:: -Visualize the total number of transactions with errors divided by the total number of transactions. -The error rate value is based on the `event.outcome` field and is the relative number of failed transactions. -Any unexpected increases, decreases, or irregular patterns can be investigated further -with the <>. +The error rate represents the percentage of failed transactions from the perspective of the selected service. +It's useful for visualizing unexpected increases, decreases, or irregular patterns in a service's transactions. ++ +[TIP] +==== +HTTP **transactions** from the HTTP server perspective do not consider a `4xx` status code (client error) as a failure +because the failure was caused by the caller, not the HTTP server. Thus, there will be no increase in error rate. + +HTTP **spans** from the client perspective however, are considered failures if the HTTP status code is ≥ 400. +These spans will increase the error rate. + +If there is no HTTP status, both transactions and spans are considered successful unless an error is reported. +==== *Average duration by span type*:: Visualize where your application is spending most of its time. diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index df5ce62cc07af..6ca7a83ac0a03 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -87,7 +87,9 @@ readonly links: { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index da3ae17171c81..3847ab0c6183a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: string;
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
readonly drilldownsTriggerPicker: string;
readonly urlDrilldownTemplateSyntax: string;
readonly urlDrilldownVariables: string;
};
readonly discover: Record<string, string>;
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly elasticsearchModule: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
readonly configure: string;
readonly httpEndpoint: string;
readonly install: string;
readonly start: string;
};
readonly enterpriseSearch: {
readonly base: string;
readonly appSearchBase: string;
readonly workplaceSearchBase: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly composite: string;
readonly composite_missing_bucket: string;
readonly date_histogram: string;
readonly date_range: string;
readonly date_format_pattern: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly runtimeFields: {
readonly mapping: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessLangSpec: string;
readonly painlessSyntax: string;
readonly painlessWalkthrough: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly introduction: string;
readonly fieldFormattersNumber: string;
readonly fieldFormattersString: string;
};
readonly addData: string;
readonly kibana: string;
readonly elasticsearch: Record<string, string>;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly eql: string;
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
readonly dateMathIndexNames: string;
};
readonly management: Record<string, string>;
readonly ml: Record<string, string>;
readonly transforms: Record<string, string>;
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
createIndex: string;
createSnapshotLifecyclePolicy: string;
createRoleMapping: string;
createRoleMappingTemplates: string;
createApiKey: string;
createPipeline: string;
createTransformRequest: string;
cronExpressions: string;
executeWatchActionModes: string;
indexExists: string;
openIndex: string;
putComponentTemplate: string;
painlessExecute: string;
painlessExecuteAPIContexts: string;
putComponentTemplateMetadata: string;
putSnapshotLifecyclePolicy: string;
putWatch: string;
updateTransform: string;
}>;
readonly observability: Record<string, string>;
readonly alerting: Record<string, string>;
readonly maps: Record<string, string>;
readonly monitoring: Record<string, string>;
readonly security: Readonly<{
apiKeyServiceSettings: string;
clusterPrivileges: string;
elasticsearchSettings: string;
elasticsearchEnableSecurity: string;
indicesPrivileges: string;
kibanaTLS: string;
kibanaPrivileges: string;
mappingRoles: string;
mappingRolesFieldRules: string;
runAsPrivilege: string;
}>;
readonly watcher: Record<string, string>;
readonly ccs: Record<string, string>;
readonly plugins: Record<string, string>;
readonly snapshotRestore: Record<string, string>;
readonly ingest: Record<string, string>;
} | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md index bcf220a9a27e6..d5641107a88aa 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -9,7 +9,7 @@ Fetch this source from Elasticsearch, returning an observable over the response( Signature: ```typescript -fetch$(options?: ISearchOptions): import("rxjs").Observable>; +fetch$(options?: ISearchOptions): import("rxjs").Observable>; ``` ## Parameters @@ -20,5 +20,5 @@ fetch$(options?: ISearchOptions): import("rxjs").ObservableReturns: -`import("rxjs").Observable>` +`import("rxjs").Observable>` diff --git a/docs/maps/images/gs_add_cloropeth_layer.png b/docs/maps/images/gs_add_cloropeth_layer.png index 1528f404026f2..42e00ccc5dd24 100644 Binary files a/docs/maps/images/gs_add_cloropeth_layer.png and b/docs/maps/images/gs_add_cloropeth_layer.png differ diff --git a/docs/maps/images/gs_add_es_document_layer.png b/docs/maps/images/gs_add_es_document_layer.png index f4ffbc581745d..d7616c4b11fe0 100644 Binary files a/docs/maps/images/gs_add_es_document_layer.png and b/docs/maps/images/gs_add_es_document_layer.png differ diff --git a/docs/maps/images/sample_data_web_logs.png b/docs/maps/images/sample_data_web_logs.png index 3b0c2ba3f12c0..f4f4de88f1992 100644 Binary files a/docs/maps/images/sample_data_web_logs.png and b/docs/maps/images/sample_data_web_logs.png differ diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index c62aafac00d3f..39ea4daf2ba33 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -67,8 +67,9 @@ and lighter shades will symbolize countries with less traffic. . In **Layer style**, set: -** **Fill color** to the grey color ramp +** **Fill color: As number** to the grey color ramp ** **Border color** to white +** **Label** to symbol label . Click **Save & close**. + @@ -102,7 +103,7 @@ The layer is only visible when users zoom in. . In **Layer settings**, set: ** **Name** to `Actual Requests` -** **Visibilty** to the range [9, 24] +** **Visibility** to the range [9, 24] ** **Opacity** to 100% . Add a tooltip field and select **agent**, **bytes**, **clientip**, **host**, @@ -134,9 +135,9 @@ grids with less bytes transferred. ** **Name** to `Total Requests and Bytes` ** **Visibility** to the range [0, 9] ** **Opacity** to 100% -. Add a metric with: -** **Aggregation** set to **Sum** -** **Field** set to **bytes** +. In **Metrics**, use: +** **Agregation** set to **Count**, and +** **Aggregation** set to **Sum** with **Field** set to **bytes** . In **Layer style**, change **Symbol size**: ** Set the field select to *sum bytes*. ** Set the min size to 7 and the max size to 25 px. diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index c96b294c0c50d..5e75aef0d9570 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -706,3 +706,21 @@ These rough calculations give you a lower bound to the required throughput, whic Given these inferred attributes, it would be safe to assume that a single {kib} instance with default settings **would not** provide the required throughput. It is possible that scaling horizontally by adding a couple more {kib} instances will. For details on scaling Task Manager, see <>. + +[float] +[[task-manager-cannot-operate-when-inline-scripts-are-disabled]] +==== Inline scripts are disabled in {es} + +*Problem*: + +Tasks are not running, and the server logs contain the following error message: + +[source, txt] +-------------------------------------------------- +[warning][plugins][taskManager] Task Manager cannot operate when inline scripts are disabled in {es} +-------------------------------------------------- + +*Solution*: + +Inline scripts are a hard requirement for Task Manager to function. +To enable inline scripting, see the Elasticsearch documentation for {ref}/modules-scripting-security.html#allowed-script-types-setting[configuring allowed script types setting]. diff --git a/package.json b/package.json index 66a6ef1d4558b..32def9e2e9fc6 100644 --- a/package.json +++ b/package.json @@ -95,18 +95,23 @@ "yarn": "^1.21.1" }, "dependencies": { + "@elastic/apm-rum": "^5.6.1", + "@elastic/apm-rum-react": "^1.2.5", + "@elastic/charts": "26.0.0", "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.4", "@elastic/ems-client": "7.12.0", "@elastic/eui": "31.7.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", + "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", "@elastic/react-search-ui": "^1.5.1", "@elastic/request-crypto": "1.1.4", "@elastic/safer-lodash-set": "link:packages/elastic-safer-lodash-set", "@elastic/search-ui-app-search-connector": "^1.5.0", + "@elastic/ui-ace": "0.2.3", "@hapi/boom": "^9.1.1", "@hapi/cookie": "^11.0.2", "@hapi/good-squeeze": "6.0.0", @@ -131,9 +136,15 @@ "@kbn/tinymath": "link:packages/kbn-tinymath", "@kbn/ui-framework": "link:packages/kbn-ui-framework", "@kbn/ui-shared-deps": "link:packages/kbn-ui-shared-deps", + "@kbn/utility-types": "link:packages/kbn-utility-types", "@kbn/utils": "link:packages/kbn-utils", "@loaders.gl/core": "^2.3.1", "@loaders.gl/json": "^2.3.1", + "@mapbox/geojson-rewind": "^0.5.0", + "@mapbox/mapbox-gl-draw": "^1.2.0", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@mapbox/vector-tile": "1.3.1", + "@scant/router": "^0.1.1", "@slack/webhook": "^5.0.4", "@turf/along": "6.0.1", "@turf/area": "6.0.1", @@ -151,41 +162,59 @@ "accept": "3.0.2", "ajv": "^6.12.4", "angular": "^1.8.0", + "angular-aria": "^1.8.0", "angular-elastic": "^2.5.1", + "angular-recursion": "^1.0.5", "angular-resource": "1.8.0", + "angular-route": "^1.8.0", "angular-sanitize": "^1.8.0", + "angular-sortable-view": "^0.0.17", "angular-ui-ace": "0.2.3", "antlr4ts": "^0.5.0-alpha.3", "apollo-cache-inmemory": "1.6.2", "apollo-client": "^2.3.8", + "apollo-link": "^1.2.3", + "apollo-link-error": "^1.1.7", "apollo-link-http": "^1.5.16", "apollo-link-http-common": "^0.2.15", "apollo-link-schema": "^1.1.0", + "apollo-link-state": "^0.4.1", "apollo-server-core": "^1.3.6", "apollo-server-errors": "^2.0.2", "apollo-server-hapi": "^1.3.6", "archiver": "^5.2.0", "axios": "^0.21.1", + "base64-js": "^1.3.1", "bluebird": "3.5.5", "brace": "0.11.1", + "broadcast-channel": "^3.0.3", "chalk": "^4.1.0", "check-disk-space": "^2.1.0", + "cheerio": "0.22.0", "chokidar": "^3.4.3", "chroma-js": "^1.4.1", "classnames": "2.2.6", "color": "1.0.3", "commander": "^3.0.2", + "compare-versions": "3.5.1", "concat-stream": "1.6.2", + "constate": "^1.3.2", + "cronstrue": "^1.51.0", "content-disposition": "0.5.3", + "copy-to-clipboard": "^3.0.8", "core-js": "^3.6.5", "custom-event-polyfill": "^0.3.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", + "d3": "3.5.17", "d3-array": "1.2.4", + "d3-cloud": "1.2.5", + "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", "dedent": "^0.7.0", "deep-freeze-strict": "^1.1.1", + "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.10.0", "elasticsearch": "^16.7.0", @@ -194,9 +223,11 @@ "expiry-js": "0.1.7", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.1", + "file-saver": "^1.3.8", "file-type": "^10.9.0", "focus-trap-react": "^3.1.1", "font-awesome": "4.7.0", + "formsy-react": "^1.1.5", "fp-ts": "^2.3.1", "geojson-vt": "^3.2.1", "get-port": "^5.0.0", @@ -212,31 +243,51 @@ "graphql-tag": "^2.10.3", "graphql-tools": "^3.0.2", "handlebars": "4.7.7", + "he": "^1.2.0", "history": "^4.9.0", + "history-extra": "^5.0.1", "hjson": "3.2.1", "http-proxy-agent": "^2.1.0", "https-proxy-agent": "^5.0.0", + "i18n-iso-countries": "^4.3.1", + "icalendar": "0.7.1", "idx": "^2.5.6", "immer": "^8.0.1", "inline-style": "^2.0.0", "intl": "^1.2.5", "intl-format-cache": "^2.1.0", "intl-messageformat": "^2.2.0", + "intl-messageformat-parser": "^1.4.0", "intl-relativeformat": "^2.1.0", "io-ts": "^2.0.5", "ipaddr.js": "2.0.0", "isbinaryfile": "4.0.2", "joi": "^13.5.2", "jquery": "^3.5.0", + "js-levenshtein": "^1.1.6", + "js-search": "^1.4.3", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", + "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", "jsonwebtoken": "^8.5.1", + "jsts": "^1.6.2", + "kea": "^2.3.0", + "leaflet": "1.5.1", + "leaflet-draw": "0.4.14", + "leaflet-responsive-popup": "0.6.4", + "leaflet.heat": "0.2.0", + "less": "npm:@elastic/less@2.7.3-kibana", "load-json-file": "^6.2.0", + "loader-utils": "^1.2.3", "lodash": "^4.17.21", "lru-cache": "^4.1.5", + "lz-string": "^1.4.4", "markdown-it": "^10.0.0", + "mapbox-gl": "1.13.1", + "mapbox-gl-draw-rectangle-mode": "^1.0.4", "md5": "^2.1.0", + "memoize-one": "^5.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "0.8.0", @@ -261,38 +312,69 @@ "papaparse": "^5.2.0", "pdfmake": "^0.1.65", "pegjs": "0.10.0", + "p-limit": "^3.0.1", + "pluralize": "3.1.0", "pngjs": "^3.4.0", + "polished": "^1.9.2", "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "proxy-from-env": "1.0.0", + "proxyquire": "1.8.0", "puid": "1.0.7", "puppeteer": "npm:@elastic/puppeteer@5.4.1-patch.1", "query-string": "^6.13.2", "raw-loader": "^3.1.0", + "rbush": "^3.0.1", + "re-resizable": "^6.1.1", "re2": "^1.15.4", "react": "^16.12.0", "react-ace": "^5.9.0", + "react-apollo": "^2.1.4", + "react-beautiful-dnd": "^13.0.0", "react-color": "^2.13.8", "react-datetime": "^2.14.0", "react-dom": "^16.12.0", + "react-dropzone": "^4.2.9", + "react-fast-compare": "^2.0.4", + "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", "react-intl": "^2.8.0", "react-is": "^16.8.0", + "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", + "react-monaco-editor": "^0.41.2", + "react-popper-tooltip": "^2.10.1", "react-query": "^3.12.0", + "react-resize-detector": "^4.2.0", + "react-reverse-portal": "^1.0.4", + "react-router-redux": "^4.0.8", + "react-shortcuts": "^2.0.0", + "react-sizeme": "^2.3.6", + "react-syntax-highlighter": "^15.3.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", + "react-tiny-virtual-list": "^2.2.0", + "react-virtualized": "^9.21.2", "react-use": "^15.3.8", + "react-vis": "^1.8.1", + "react-visibility-sensor": "^5.1.1", + "reactcss": "1.2.3", "recompose": "^0.26.0", + "reduce-reducers": "^1.0.4", "redux": "^4.0.5", "redux-actions": "^2.6.5", + "redux-devtools-extension": "^2.13.8", "redux-observable": "^1.2.0", + "redux-saga": "^1.1.3", "redux-thunk": "^2.3.0", + "redux-thunks": "^1.0.0", "regenerator-runtime": "^0.13.3", "request": "^2.88.0", "require-in-the-middle": "^5.0.2", + "reselect": "^4.0.0", + "resize-observer-polyfill": "^1.5.0", "rison-node": "1.0.2", "rxjs": "^6.5.5", "seedrandom": "^3.0.5", @@ -305,17 +387,30 @@ "style-it": "^2.1.3", "styled-components": "^5.1.0", "symbol-observable": "^1.2.0", + "suricata-sid-db": "^1.0.2", "tabbable": "1.1.3", "tar": "4.4.13", + "tinycolor2": "1.4.1", "tinygradient": "0.4.3", + "topojson-client": "3.0.0", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", "type-detect": "^4.0.8", + "typescript-fsa": "^3.0.0", + "typescript-fsa-reducers": "^1.2.2", "ui-select": "0.19.8", "unified": "^9.2.1", + "unstated": "^2.1.1", + "use-resize-observer": "^6.0.0", "utility-types": "^3.10.0", "uuid": "3.3.2", + "vega": "^5.19.1", + "vega-lite": "^4.17.0", + "vega-schema-url-parser": "^2.1.0", + "vega-spec-injector": "^0.0.2", + "vega-tooltip": "^0.25.0", + "venn.js": "0.2.20", "vinyl": "^2.2.0", "vt-pbf": "^3.1.1", "wellknown": "^0.5.0", @@ -347,13 +442,10 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "25.3.0", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", "@elastic/makelogs": "^6.0.0", - "@elastic/maki": "6.3.0", - "@elastic/ui-ace": "0.2.3", "@istanbuljs/schema": "^0.1.2", "@jest/reporters": "^26.5.2", "@kbn/babel-code-parser": "link:packages/kbn-babel-code-parser", @@ -373,17 +465,11 @@ "@kbn/telemetry-tools": "link:packages/kbn-telemetry-tools", "@kbn/test": "link:packages/kbn-test", "@kbn/test-subj-selector": "link:packages/kbn-test-subj-selector", - "@kbn/utility-types": "link:packages/kbn-utility-types", "@loaders.gl/polyfills": "^2.3.5", - "@mapbox/geojson-rewind": "^0.5.0", - "@mapbox/mapbox-gl-draw": "^1.2.0", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", - "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.7.2", "@microsoft/api-extractor": "7.7.0", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@scant/router": "^0.1.0", "@storybook/addon-a11y": "^6.1.20", "@storybook/addon-actions": "^6.1.20", "@storybook/addon-docs": "^6.1.20", @@ -456,7 +542,6 @@ "@types/he": "^1.1.1", "@types/history": "^4.7.3", "@types/hjson": "^2.4.2", - "@types/hoist-non-react-statics": "^3.3.1", "@types/http-proxy": "^1.17.4", "@types/http-proxy-agent": "^2.0.2", "@types/inquirer": "^7.3.1", @@ -476,7 +561,6 @@ "@types/listr": "^0.14.0", "@types/loader-utils": "^1.1.3", "@types/lodash": "^4.14.159", - "@types/log-symbols": "^2.0.0", "@types/lru-cache": "^5.1.0", "@types/mapbox-gl": "^1.9.1", "@types/markdown-it": "^0.0.7", @@ -563,21 +647,13 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^4.14.1", "@typescript-eslint/parser": "^4.14.1", - "@welldone-software/why-did-you-render": "^5.0.0", "@yarnpkg/lockfile": "^1.1.0", "abab": "^2.0.4", "aggregate-error": "^3.1.0", - "angular-aria": "^1.8.0", "angular-mocks": "^1.7.9", - "angular-recursion": "^1.0.5", - "angular-route": "^1.8.0", - "angular-sortable-view": "^0.0.17", "antlr4ts-cli": "^0.5.0-alpha.3", "apidoc": "^0.25.0", "apidoc-markdown": "^5.1.8", - "apollo-link": "^1.2.3", - "apollo-link-error": "^1.1.7", - "apollo-link-state": "^0.4.1", "argsplit": "^1.0.5", "autoprefixer": "^9.7.4", "axe-core": "^4.0.2", @@ -590,34 +666,23 @@ "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "^5.6.6", - "base64-js": "^1.3.1", "base64url": "^3.0.1", - "broadcast-channel": "^3.0.3", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "cheerio": "0.22.0", "chromedriver": "^89.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", - "compare-versions": "3.5.1", "compression-webpack-plugin": "^4.0.0", - "constate": "^1.3.2", - "copy-to-clipboard": "^3.0.8", "copy-webpack-plugin": "^6.0.2", "cpy": "^8.1.1", - "cronstrue": "^1.51.0", "css-loader": "^3.4.2", "cypress": "^6.2.1", "cypress-cucumber-preprocessor": "^2.5.2", "cypress-multi-reporters": "^1.4.0", "cypress-pipe": "^2.0.0", "cypress-promise": "^1.1.0", - "d3": "3.5.17", - "d3-cloud": "1.2.5", - "d3-scale": "1.0.7", "debug": "^2.6.9", - "deepmerge": "^4.2.2", "del-cli": "^3.0.1", "delete-empty": "^2.0.0", "dependency-check": "^4.1.0", @@ -654,9 +719,7 @@ "fast-glob": "2.2.7", "fetch-mock": "^7.3.9", "file-loader": "^4.2.0", - "file-saver": "^1.3.8", "form-data": "^4.0.0", - "formsy-react": "^1.1.5", "geckodriver": "^1.22.2", "glob-watcher": "5.0.3", "graphql-code-generator": "^0.18.2", @@ -675,19 +738,10 @@ "gulp-zip": "^5.0.2", "has-ansi": "^3.0.0", "hdr-histogram-js": "^1.2.0", - "he": "^1.2.0", - "highlight.js": "^9.18.5", - "history-extra": "^5.0.1", - "hoist-non-react-statics": "^3.3.2", "html": "1.0.0", "html-loader": "^0.5.5", "http-proxy": "^1.18.1", - "i18n-iso-countries": "^4.3.1", - "icalendar": "0.7.1", - "iedriver": "^3.14.2", - "imports-loader": "^0.8.0", "inquirer": "^7.3.3", - "intl-messageformat-parser": "^1.4.0", "is-glob": "^4.0.1", "is-path-inside": "^3.0.2", "istanbul-instrumenter-loader": "^3.0.1", @@ -705,31 +759,14 @@ "jest-styled-components": "^7.0.2", "jest-when": "^2.7.2", "jimp": "^0.14.0", - "js-levenshtein": "^1.1.6", - "js-search": "^1.4.3", "jsdom": "13.1.0", - "json-stringify-pretty-compact": "1.2.0", "json5": "^1.0.1", "jsondiffpatch": "0.4.1", - "jsts": "^1.6.2", - "kea": "^2.3.0", - "keymirror": "0.1.1", - "leaflet": "1.5.1", - "leaflet-draw": "0.4.14", - "leaflet-responsive-popup": "0.6.4", - "leaflet.heat": "0.2.0", - "less": "npm:@elastic/less@2.7.3-kibana", "license-checker": "^16.0.0", "listr": "^0.14.1", "lmdb-store": "^0.9.0", "load-grunt-config": "^3.0.1", - "loader-utils": "^1.2.3", - "log-symbols": "^2.2.0", - "lz-string": "^1.4.4", - "mapbox-gl": "1.13.1", - "mapbox-gl-draw-rectangle-mode": "^1.0.4", "marge": "^1.0.1", - "memoize-one": "^5.0.0", "micromatch": "3.1.10", "minimist": "^1.2.5", "mkdirp": "0.5.1", @@ -741,8 +778,6 @@ "mock-http-server": "1.3.0", "ms-chromium-edge-driver": "^0.2.3", "multimatch": "^4.0.0", - "multistream": "^2.1.1", - "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", "ncp": "^2.0.0", "node-sass": "^4.14.1", @@ -750,53 +785,19 @@ "nyc": "^15.0.1", "oboe": "^2.1.4", "ora": "^4.0.4", - "p-limit": "^3.0.1", "parse-link-header": "^1.0.1", "pbf": "3.2.1", "pirates": "^4.0.1", "pixelmatch": "^5.1.0", - "pkg-up": "^2.0.0", - "pluralize": "3.1.0", - "polished": "^1.9.2", "postcss": "^7.0.32", "postcss-loader": "^3.0.0", "postcss-prefix-selector": "^1.7.2", "prettier": "^2.2.0", "pretty-ms": "5.0.0", - "proxyquire": "1.8.0", "q": "^1.5.1", - "querystring": "^0.2.0", - "rbush": "^3.0.1", - "re-resizable": "^6.1.1", - "react-apollo": "^2.1.4", - "react-beautiful-dnd": "^13.0.0", - "react-docgen-typescript-loader": "^3.1.1", - "react-dropzone": "^4.2.9", - "react-fast-compare": "^2.0.4", - "react-grid-layout": "^0.16.2", - "react-markdown": "^4.3.1", - "react-monaco-editor": "^0.41.2", - "react-popper-tooltip": "^2.10.1", - "react-resize-detector": "^4.2.0", - "react-reverse-portal": "^1.0.4", - "react-router-redux": "^4.0.8", - "react-shortcuts": "^2.0.0", - "react-sizeme": "^2.3.6", - "react-syntax-highlighter": "^15.3.1", "react-test-renderer": "^16.12.0", - "react-tiny-virtual-list": "^2.2.0", - "react-virtualized": "^9.21.2", - "react-vis": "^1.8.1", - "react-visibility-sensor": "^5.1.1", - "reactcss": "1.2.3", "read-pkg": "^5.2.0", - "reduce-reducers": "^1.0.4", - "redux-devtools-extension": "^2.13.8", - "redux-saga": "^1.1.3", - "redux-thunks": "^1.0.0", "regenerate": "^1.4.0", - "reselect": "^4.0.0", - "resize-observer-polyfill": "^1.5.0", "resolve": "^1.7.1", "rxjs-marbles": "^5.0.6", "sass-loader": "^8.0.2", @@ -816,31 +817,18 @@ "supertest": "^3.1.0", "supertest-as-promised": "^4.0.2", "supports-color": "^7.0.0", - "suricata-sid-db": "^1.0.2", "tape": "^5.0.1", "tar-fs": "^2.1.0", "tempy": "^0.3.0", "terminal-link": "^2.1.1", "terser-webpack-plugin": "^2.1.2", - "tinycolor2": "1.4.1", - "topojson-client": "3.0.0", "ts-loader": "^7.0.5", "ts-morph": "^9.1.0", "tsd": "^0.13.1", "typescript": "4.1.3", - "typescript-fsa": "^3.0.0", - "typescript-fsa-reducers": "^1.2.2", "unlazy-loader": "^0.1.3", - "unstated": "^2.1.1", "url-loader": "^2.2.0", - "use-resize-observer": "^6.0.0", "val-loader": "^1.1.1", - "vega": "^5.19.1", - "vega-lite": "^4.17.0", - "vega-schema-url-parser": "^2.1.0", - "vega-spec-injector": "^0.0.2", - "vega-tooltip": "^0.25.0", - "venn.js": "0.2.20", "vinyl-fs": "^3.0.3", "wait-on": "^5.2.1", "watchpack": "^1.6.0", diff --git a/packages/kbn-utility-types/package.json b/packages/kbn-utility-types/package.json index a8f6e25276cec..33419ee0f1ec4 100644 --- a/packages/kbn-utility-types/package.json +++ b/packages/kbn-utility-types/package.json @@ -6,7 +6,7 @@ "main": "target", "types": "target/index.d.ts", "kibana": { - "devOnly": true + "devOnly": false }, "scripts": { "build": "../../node_modules/.bin/tsc", diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 13c16691bf12a..34b78bbd7e51e 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -68,6 +68,7 @@ function applyConfigOverrides(rawConfig, opts, extraCliOptions) { if (opts.ssl) { // @kbn/dev-utils is part of devDependencies + // eslint-disable-next-line import/no-extraneous-dependencies const { CA_CERT_PATH, KBN_KEY_PATH, KBN_CERT_PATH } = require('@kbn/dev-utils'); const customElasticsearchHosts = opts.elasticsearch ? opts.elasticsearch.split(',') diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 0bb5ddd29609e..2835fb8370b8f 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -108,7 +108,9 @@ export class DocLinksService { sum: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-sum-aggregation.html`, top_hits: `${ELASTICSEARCH_DOCS}search-aggregations-metrics-top-hits-aggregation.html`, }, - runtimeFields: `${ELASTICSEARCH_DOCS}runtime.html`, + runtimeFields: { + mapping: `${ELASTICSEARCH_DOCS}runtime-mapping-fields.html`, + }, scriptedFields: { scriptFields: `${ELASTICSEARCH_DOCS}search-request-script-fields.html`, scriptAggs: `${ELASTICSEARCH_DOCS}search-aggregations.html`, @@ -232,7 +234,7 @@ export class DocLinksService { apiKeyServiceSettings: `${ELASTICSEARCH_DOCS}security-settings.html#api-key-service-settings`, clusterPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-cluster`, elasticsearchSettings: `${ELASTICSEARCH_DOCS}security-settings.html`, - elasticsearchEnableSecurity: `${ELASTICSEARCH_DOCS}get-started-enable-security.html`, + elasticsearchEnableSecurity: `${ELASTICSEARCH_DOCS}configuring-stack-security.html`, indicesPrivileges: `${ELASTICSEARCH_DOCS}security-privileges.html#privileges-list-indices`, kibanaTLS: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`, kibanaPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-privileges.html`, @@ -381,7 +383,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 5c034e68a3736..5a5ae253bac7f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -559,7 +559,9 @@ export interface DocLinksStart { readonly sum: string; readonly top_hits: string; }; - readonly runtimeFields: string; + readonly runtimeFields: { + readonly mapping: string; + }; readonly scriptedFields: { readonly scriptFields: string; readonly scriptAggs: string; diff --git a/src/core/server/elasticsearch/integration_tests/client.test.ts b/src/core/server/elasticsearch/integration_tests/client.test.ts new file mode 100644 index 0000000000000..3a4b7c5c4af22 --- /dev/null +++ b/src/core/server/elasticsearch/integration_tests/client.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + createTestServers, + TestElasticsearchUtils, + TestKibanaUtils, +} from '../../../test_helpers/kbn_server'; + +describe('elasticsearch clients', () => { + let esServer: TestElasticsearchUtils; + let kibanaServer: TestKibanaUtils; + + beforeAll(async () => { + const { startES, startKibana } = createTestServers({ + adjustTimeout: jest.setTimeout, + }); + + esServer = await startES(); + kibanaServer = await startKibana(); + }); + + afterAll(async () => { + await kibanaServer.stop(); + await esServer.stop(); + }); + + it('does not return deprecation warning when x-elastic-product-origin header is set', async () => { + // Header should be automatically set by Core + const resp1 = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' } + ); + expect(resp1.headers).not.toHaveProperty('warning'); + + // Also test setting it explicitly + const resp2 = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' }, + { headers: { 'x-elastic-product-origin': 'kibana' } } + ); + expect(resp2.headers).not.toHaveProperty('warning'); + }); + + it('returns deprecation warning when x-elastic-product-orign header is not set', async () => { + const resp = await kibanaServer.coreStart.elasticsearch.client.asInternalUser.indices.getSettings( + { index: '.kibana' }, + { headers: { 'x-elastic-product-origin': null } } + ); + + expect(resp.headers).toHaveProperty('warning'); + expect(resp.headers!.warning).toMatch('system indices'); + }); +}); diff --git a/src/plugins/data/README.mdx b/src/plugins/data/README.mdx index 145aaa64fa3ad..60e74a3fa126c 100644 --- a/src/plugins/data/README.mdx +++ b/src/plugins/data/README.mdx @@ -21,7 +21,6 @@ It is wired into the `TopNavMenu` component, but can be used independently. ### Fetch Query Suggestions The `getQuerySuggestions` function helps to construct a query. -KQL suggestion functions are registered in X-Pack, so this API does not return results in OSS. ```.ts diff --git a/src/plugins/data/common/search/session/types.ts b/src/plugins/data/common/search/session/types.ts index f63d2dfec142c..a7ba8ab9576b6 100644 --- a/src/plugins/data/common/search/session/types.ts +++ b/src/plugins/data/common/search/session/types.ts @@ -31,6 +31,13 @@ export interface SearchSessionSavedObjectAttributes { * Expiration time of the session. Expiration itself is managed by Elasticsearch. */ expires: string; + /** + * Time of transition into completed state, + * + * Can be "null" in case already completed session + * transitioned into in-progress session + */ + completed?: string | null; /** * status */ diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index 6b288c4507f06..eb9d859664c4d 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -18,6 +18,11 @@ import { import { ConfigSchema } from '../../config'; import { UsageCollectionSetup } from '../../../usage_collection/public'; import { createUsageCollector } from './collectors'; +import { + KUERY_LANGUAGE_NAME, + setupKqlQuerySuggestionProvider, +} from './providers/kql_query_suggestion'; +import { DataPublicPluginStart, DataStartDependencies } from '../types'; export class AutocompleteService { autocompleteConfig: ConfigSchema['autocomplete']; @@ -31,12 +36,6 @@ export class AutocompleteService { private readonly querySuggestionProviders: Map = new Map(); private getValueSuggestions?: ValueSuggestionsGetFn; - private addQuerySuggestionProvider = (language: string, provider: QuerySuggestionGetFn): void => { - if (language && provider && this.autocompleteConfig.querySuggestions.enabled) { - this.querySuggestionProviders.set(language, provider); - } - }; - private getQuerySuggestions: QuerySuggestionGetFn = (args) => { const { language } = args; const provider = this.querySuggestionProviders.get(language); @@ -50,7 +49,7 @@ export class AutocompleteService { /** @public **/ public setup( - core: CoreSetup, + core: CoreSetup, { timefilter, usageCollection, @@ -62,11 +61,15 @@ export class AutocompleteService { ? setupValueSuggestionProvider(core, { timefilter, usageCollector }) : getEmptyValueSuggestions; - return { - addQuerySuggestionProvider: this.addQuerySuggestionProvider, + if (this.autocompleteConfig.querySuggestions.enabled) { + this.querySuggestionProviders.set(KUERY_LANGUAGE_NAME, setupKqlQuerySuggestionProvider(core)); + } - /** @obsolete **/ - /** please use "getProvider" only from the start contract **/ + return { + /** + * @deprecated + * please use "getQuerySuggestions" from the start contract + */ getQuerySuggestions: this.getQuerySuggestions, }; } diff --git a/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md new file mode 100644 index 0000000000000..2ab87a7a490c1 --- /dev/null +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/README.md @@ -0,0 +1 @@ +This is implementation of KQL query suggestions diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json similarity index 100% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/__fixtures__/index_pattern_response.json diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts index 5e562ae63e91b..c1c44f1f55548 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { setupGetConjunctionSuggestions } from './conjunction'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx similarity index 67% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx index 7efc2ea193abe..345f9f8051e5d 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/conjunction.tsx @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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'; @@ -16,17 +17,17 @@ import { const bothArgumentsText = ( ); const oneOrMoreArgumentsText = ( ); @@ -34,20 +35,20 @@ const conjunctions: Record = { and: (

{bothArgumentsText}, }} description="Full text: ' Requires both arguments to be true'. See - 'xpack.data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." + 'data.kueryAutocomplete.andOperatorDescription.bothArgumentsText' for 'both arguments' part." />

), or: (

= { ), }} description="Full text: 'Requires one or more arguments to be true'. See - 'xpack.data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." + 'data.kueryAutocomplete.orOperatorDescription.oneOrMoreArgumentsText' for 'one or more arguments' part." />

), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts similarity index 97% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts index afc55d13af9d9..f1eced06a33ea 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx index ac6f7de888320..5cafca168dfa2 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/field.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/field.tsx @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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'; @@ -22,7 +23,7 @@ const getDescription = (field: IFieldType) => { return (

{field.name} }} /> diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts index 8b36480a35b17..c5c1626ae74f6 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/index.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { CoreSetup } from 'kibana/public'; @@ -17,6 +18,7 @@ import { QuerySuggestion, QuerySuggestionGetFnArgs, QuerySuggestionGetFn, + DataPublicPluginStart, } from '../../../../../../../src/plugins/data/public'; const cursorSymbol = '@kuery-cursor@'; @@ -26,7 +28,9 @@ const dedup = (suggestions: QuerySuggestion[]): QuerySuggestion[] => export const KUERY_LANGUAGE_NAME = 'kuery'; -export const setupKqlQuerySuggestionProvider = (core: CoreSetup): QuerySuggestionGetFn => { +export const setupKqlQuerySuggestionProvider = ( + core: CoreSetup +): QuerySuggestionGetFn => { const providers = { field: setupGetFieldSuggestions(core), value: setupGetValueSuggestions(core), diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts index 0173617a99b1b..933449e779ef7 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { escapeQuotes, escapeKuery } from './escape_kuery'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts similarity index 85% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts index 901e61bde455d..54f03803a893e 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { flow } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts similarity index 95% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts index bd021b0d0dac5..4debbc0843d51 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 indexPatternResponse from './__fixtures__/index_pattern_response.json'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx index cfe935e4b1990..618e33ddf345a 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/operator.tsx +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/operator.tsx @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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'; @@ -15,44 +16,44 @@ import { QuerySuggestionTypes } from '../../../../../../../src/plugins/data/publ const equalsText = ( ); const lessThanOrEqualToText = ( ); const greaterThanOrEqualToText = ( ); const lessThanText = ( ); const greaterThanText = ( ); const existsText = ( ); @@ -60,11 +61,11 @@ const operators = { ':': { description: ( {equalsText} }} description="Full text: 'equals some value'. See - 'xpack.data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." + 'data.kueryAutocomplete.equalOperatorDescription.equalsText' for 'equals' part." /> ), fieldTypes: [ @@ -83,7 +84,7 @@ const operators = { '<=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -99,7 +100,7 @@ const operators = { '>=': { description: ( ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -115,11 +116,11 @@ const operators = { '<': { description: ( {lessThanText} }} description="Full text: 'is less than some value'. See - 'xpack.data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." + 'data.kueryAutocomplete.lessThanOperatorDescription.lessThanText' for 'less than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -127,13 +128,13 @@ const operators = { '>': { description: ( {greaterThanText}, }} description="Full text: 'is greater than some value'. See - 'xpack.data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." + 'data.kueryAutocomplete.greaterThanOperatorDescription.greaterThanText' for 'greater than' part." /> ), fieldTypes: ['number', 'number_range', 'date', 'date_range', 'ip', 'ip_range'], @@ -141,11 +142,11 @@ const operators = { ': *': { description: ( {existsText} }} description="Full text: 'exists in any form'. See - 'xpack.data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." + 'data.kueryAutocomplete.existOperatorDescription.existsText' for 'exists' part." /> ), fieldTypes: undefined, diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts similarity index 92% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts index aa236a45fa93c..f72fb75684105 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.test.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { sortPrefixFirst } from './sort_prefix_first'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts similarity index 76% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts index c344197641ef4..25bc32d47f338 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/sort_prefix_first.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { partition } from 'lodash'; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts similarity index 65% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts index b5abdbee51832..48e87a73f3671 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/types.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/types.ts @@ -1,17 +1,19 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { CoreSetup } from 'kibana/public'; import { + DataPublicPluginStart, KueryNode, QuerySuggestionBasic, QuerySuggestionGetFnArgs, } from '../../../../../../../src/plugins/data/public'; export type KqlQuerySuggestionProvider = ( - core: CoreSetup + core: CoreSetup ) => (querySuggestionsGetFnArgs: QuerySuggestionGetFnArgs, kueryNode: KueryNode) => Promise; diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts similarity index 93% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts index 5744dad43dcdd..c434d9a8ef365 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.test.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.test.ts @@ -1,15 +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. + * 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 { setupGetValueSuggestions } from './value'; import indexPatternResponse from './__fixtures__/index_pattern_response.json'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { QuerySuggestionGetFnArgs, KueryNode } from '../../../../../../../src/plugins/data/public'; -import { setAutocompleteService } from '../../../services'; const mockKueryNode = (kueryNode: Partial) => (kueryNode as unknown) as KueryNode; @@ -19,11 +19,6 @@ describe('Kuery value suggestions', () => { let autocompleteServiceMock: any; beforeEach(() => { - getSuggestions = setupGetValueSuggestions(coreMock.createSetup()); - querySuggestionsArgs = ({ - indexPatterns: [indexPatternResponse], - } as unknown) as QuerySuggestionGetFnArgs; - autocompleteServiceMock = { getValueSuggestions: jest.fn(({ field }) => { let res: any[]; @@ -40,7 +35,16 @@ describe('Kuery value suggestions', () => { return Promise.resolve(res); }), }; - setAutocompleteService(autocompleteServiceMock); + + const coreSetup = coreMock.createSetup({ + pluginStartContract: { + autocomplete: autocompleteServiceMock, + }, + }); + getSuggestions = setupGetValueSuggestions(coreSetup); + querySuggestionsArgs = ({ + indexPatterns: [indexPatternResponse], + } as unknown) as QuerySuggestionGetFnArgs; jest.clearAllMocks(); }); diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts similarity index 79% rename from x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts rename to src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts index 92fd4d7b71bdc..f8fc9d165fc6b 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/providers/kql_query_suggestion/value.ts +++ b/src/plugins/data/public/autocomplete/providers/kql_query_suggestion/value.ts @@ -1,15 +1,17 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 { flatten } from 'lodash'; +import { CoreSetup } from 'kibana/public'; import { escapeQuotes } from './lib/escape_kuery'; import { KqlQuerySuggestionProvider } from './types'; -import { getAutocompleteService } from '../../../services'; import { + DataPublicPluginStart, IFieldType, IIndexPattern, QuerySuggestion, @@ -26,7 +28,12 @@ const wrapAsSuggestions = (start: number, end: number, query: string, values: st end, })); -export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { +export const setupGetValueSuggestions: KqlQuerySuggestionProvider = ( + core: CoreSetup +) => { + const autoCompleteServicePromise = core + .getStartServices() + .then(([_, __, dataStart]) => dataStart.autocomplete); return async ( { indexPatterns, boolFilter, useTimeRange, signal }, { start, end, prefix, suffix, fieldName, nestedPath } @@ -41,7 +48,7 @@ export const setupGetValueSuggestions: KqlQuerySuggestionProvider = () => { }); const query = `${prefix}${suffix}`.trim(); - const { getValueSuggestions } = getAutocompleteService(); + const { getValueSuggestions } = await autoCompleteServicePromise; const data = await Promise.all( indexPatternFieldEntries.map(([indexPattern, field]) => diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index a3676c5116927..573820890de71 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -17,7 +17,6 @@ export type Setup = jest.Mocked>; export type Start = jest.Mocked>; const automcompleteSetupMock: jest.Mocked = { - addQuerySuggestionProvider: jest.fn(), getQuerySuggestions: jest.fn(), }; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4eae5629af3a6..e4085abe14050 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -23,7 +23,7 @@ import * as CSS from 'csstype'; import { Datatable as Datatable_2 } from 'src/plugins/expressions'; import { Datatable as Datatable_3 } from 'src/plugins/expressions/common'; import { DatatableColumn as DatatableColumn_2 } from 'src/plugins/expressions'; -import { DatatableColumnType } from 'src/plugins/expressions/common'; +import { DatatableColumnType as DatatableColumnType_2 } from 'src/plugins/expressions/common'; import { DetailedPeerCertificate } from 'tls'; import { Ensure } from '@kbn/utility-types'; import { EnvironmentMode } from '@kbn/config'; @@ -85,8 +85,8 @@ import { RequestAdapter } from 'src/plugins/inspector/common'; import { RequestStatistics as RequestStatistics_2 } from 'src/plugins/inspector/common'; import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; -import { SavedObject } from 'kibana/server'; -import { SavedObject as SavedObject_2 } from 'src/core/server'; +import { SavedObject } from 'src/core/server'; +import { SavedObject as SavedObject_2 } from 'kibana/server'; import { SavedObjectReference } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectsFindOptions } from 'kibana/public'; @@ -188,7 +188,7 @@ export class AggConfig { // @deprecated (undocumented) toJSON(): AggConfigSerialized; // Warning: (ae-forgotten-export) The symbol "SerializableState" needs to be exported by the entry point index.d.ts - toSerializedFieldFormat(): {} | Ensure, SerializableState>; + toSerializedFieldFormat(): {} | Ensure, SerializableState_2>; // (undocumented) get type(): IAggType; set type(type: IAggType); @@ -272,9 +272,9 @@ export type AggConfigSerialized = Ensure<{ type: string; enabled?: boolean; id?: string; - params?: {} | SerializableState; + params?: {} | SerializableState_2; schema?: string; -}, SerializableState>; +}, SerializableState_2>; // Warning: (ae-missing-release-tag) "AggFunctionsMapping" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1604,7 +1604,7 @@ export class IndexPatternsService { // Warning: (ae-forgotten-export) The symbol "IndexPatternSavedObjectAttrs" needs to be exported by the entry point index.d.ts // // (undocumented) - getCache: () => Promise[] | null | undefined>; + getCache: () => Promise[] | null | undefined>; getDefault: () => Promise; getFieldsForIndexPattern: (indexPattern: IndexPattern | IndexPatternSpec, options?: GetFieldsOptions | undefined) => Promise; // Warning: (ae-forgotten-export) The symbol "GetFieldsOptions" needs to be exported by the entry point index.d.ts @@ -1616,7 +1616,7 @@ export class IndexPatternsService { }>>; getTitles: (refresh?: boolean) => Promise; refreshFields: (indexPattern: IndexPattern) => Promise; - savedObjectToSpec: (savedObject: SavedObject_2) => IndexPatternSpec; + savedObjectToSpec: (savedObject: SavedObject) => IndexPatternSpec; setDefault: (id: string, force?: boolean) => Promise; updateSavedObject(indexPattern: IndexPattern, saveAttempts?: number, ignoreErrors?: boolean): Promise; } @@ -2704,7 +2704,7 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:427:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:430:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/search/session/session_service.ts:55:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/search/session/session_service.ts:56:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index 8ee44cb2ca4ef..18d32463864e3 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -9,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'; import { ISessionsClient } from './sessions_client'; import { ISessionService } from './session_service'; -import { SearchSessionState } from './search_session_state'; +import { SearchSessionState, SessionMeta } from './search_session_state'; export function getSessionsClientMock(): jest.Mocked { return { @@ -31,7 +31,9 @@ export function getSessionServiceMock(): jest.Mocked { getSessionId: jest.fn(), getSession$: jest.fn(() => new BehaviorSubject(undefined).asObservable()), state$: new BehaviorSubject(SearchSessionState.None).asObservable(), - searchSessionName$: new BehaviorSubject(undefined).asObservable(), + sessionMeta$: new BehaviorSubject({ + state: SearchSessionState.None, + }).asObservable(), renameCurrentSession: jest.fn(), trackSearch: jest.fn((searchDescriptor) => () => {}), destroy: jest.fn(), diff --git a/src/plugins/data/public/search/session/search_session_state.ts b/src/plugins/data/public/search/session/search_session_state.ts index e58e1062091bf..bf9036d361a8f 100644 --- a/src/plugins/data/public/search/session/search_session_state.ts +++ b/src/plugins/data/public/search/session/search_session_state.ts @@ -7,6 +7,7 @@ */ import uuid from 'uuid'; +import deepEqual from 'fast-deep-equal'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { createStateContainer, StateContainer } from '../../../../kibana_utils/public'; @@ -107,9 +108,19 @@ export interface SessionStateInternal { isCanceled: boolean; /** - * Start time of current session + * Start time of the current session (from browser perspective) */ startTime?: Date; + + /** + * Time when all the searches from the current session are completed (from browser perspective) + */ + completedTime?: Date; + + /** + * Time when the session was canceled by user, by hitting "stop" + */ + canceledTime?: Date; } const createSessionDefaultState: < @@ -170,12 +181,15 @@ export const sessionPureTransitions: SessionPureTransitions = { ...state, isStarted: true, pendingSearches: state.pendingSearches.concat(search), + completedTime: undefined, }; }, unTrackSearch: (state) => (search) => { + const pendingSearches = state.pendingSearches.filter((s) => s !== search); return { ...state, - pendingSearches: state.pendingSearches.filter((s) => s !== search), + pendingSearches, + completedTime: pendingSearches.length === 0 ? new Date() : state.completedTime, }; }, cancel: (state) => () => { @@ -185,6 +199,7 @@ export const sessionPureTransitions: SessionPureTransitions = { ...state, pendingSearches: [], isCanceled: true, + canceledTime: new Date(), isStored: false, searchSessionSavedObject: undefined, }; @@ -205,11 +220,24 @@ export const sessionPureTransitions: SessionPureTransitions = { }, }; +/** + * Consolidate meta info about current seach session + * Contains both deferred properties and plain properties from state + */ +export interface SessionMeta { + state: SearchSessionState; + name?: string; + startTime?: Date; + canceledTime?: Date; + completedTime?: Date; +} + export interface SessionPureSelectors< SearchDescriptor = unknown, S = SessionStateInternal > { getState: (state: S) => () => SearchSessionState; + getMeta: (state: S) => () => SessionMeta; } export const sessionPureSelectors: SessionPureSelectors = { @@ -233,6 +261,21 @@ export const sessionPureSelectors: SessionPureSelectors = { } return SearchSessionState.None; }, + getMeta(state) { + const sessionState = this.getState(state)(); + + return () => ({ + state: sessionState, + name: state.searchSessionSavedObject?.attributes.name, + startTime: state.searchSessionSavedObject?.attributes.created + ? new Date(state.searchSessionSavedObject?.attributes.created) + : state.startTime, + completedTime: state.searchSessionSavedObject?.attributes.completed + ? new Date(state.searchSessionSavedObject?.attributes.completed) + : state.completedTime, + canceledTime: state.canceledTime, + }); + }, }; export type SessionStateContainer = StateContainer< @@ -246,9 +289,7 @@ export const createSessionStateContainer = ( ): { stateContainer: SessionStateContainer; sessionState$: Observable; - sessionStartTime$: Observable; - searchSessionSavedObject$: Observable; - searchSessionName$: Observable; + sessionMeta$: Observable; } => { const stateContainer = createStateContainer( createSessionDefaultState(), @@ -257,33 +298,20 @@ export const createSessionStateContainer = ( freeze ? undefined : { freeze: (s) => s } ) as SessionStateContainer; - const sessionState$: Observable = stateContainer.state$.pipe( - map(() => stateContainer.selectors.getState()), - distinctUntilChanged(), - shareReplay(1) - ); - - const sessionStartTime$: Observable = stateContainer.state$.pipe( - map(() => stateContainer.get().startTime), - distinctUntilChanged(), - shareReplay(1) - ); - - const searchSessionSavedObject$ = stateContainer.state$.pipe( - map(() => stateContainer.get().searchSessionSavedObject), - distinctUntilChanged(), + const sessionMeta$: Observable = stateContainer.state$.pipe( + map(() => stateContainer.selectors.getMeta()), + distinctUntilChanged(deepEqual), shareReplay(1) ); - const searchSessionName$ = searchSessionSavedObject$.pipe( - map((savedObject) => savedObject?.attributes?.name) + const sessionState$: Observable = sessionMeta$.pipe( + map((meta) => meta.state), + distinctUntilChanged() ); return { stateContainer, sessionState$, - sessionStartTime$, - searchSessionSavedObject$, - searchSessionName$, + sessionMeta$, }; }; diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 785b9357fc895..381410574ecda 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -20,6 +20,7 @@ import { ConfigSchema } from '../../../config'; import { createSessionStateContainer, SearchSessionState, + SessionMeta, SessionStateContainer, } from './search_session_state'; import { ISessionsClient } from './sessions_client'; @@ -78,7 +79,7 @@ export class SessionService { public readonly state$: Observable; private readonly state: SessionStateContainer; - public readonly searchSessionName$: Observable; + public readonly sessionMeta$: Observable; private searchSessionInfoProvider?: SearchSessionInfoProvider; private searchSessionIndicatorUiConfig?: Partial; private subscription = new Subscription(); @@ -97,20 +98,24 @@ export class SessionService { const { stateContainer, sessionState$, - sessionStartTime$, - searchSessionName$, + sessionMeta$, } = createSessionStateContainer({ freeze: freezeState, }); this.state$ = sessionState$; this.state = stateContainer; - this.searchSessionName$ = searchSessionName$; + this.sessionMeta$ = sessionMeta$; this.subscription.add( - sessionStartTime$.subscribe((startTime) => { - if (startTime) this.nowProvider.set(startTime); - else this.nowProvider.reset(); - }) + sessionMeta$ + .pipe( + map((meta) => meta.startTime), + distinctUntilChanged() + ) + .subscribe((startTime) => { + if (startTime) this.nowProvider.set(startTime); + else this.nowProvider.reset(); + }) ); getStartServices().then(([coreStart]) => { diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 21560b1328840..9c95878af631e 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -7,7 +7,14 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"], + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "config.ts", + "common/**/*.json", + "public/**/*.json" + ], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../bfetch/tsconfig.json" }, @@ -16,6 +23,6 @@ { "path": "../inspector/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, - { "path": "../kibana_react/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" } ] } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts index 18eccb4e87090..c57333d788ef5 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/shared_imports.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +// eslint-disable-next-line import/no-extraneous-dependencies export { registerTestBed, TestBed } from '@kbn/test/jest'; +// eslint-disable-next-line import/no-extraneous-dependencies export { getRandomString } from '@kbn/test/jest'; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index 9b69dacd8fdb5..cfac42b97c686 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -109,7 +109,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Promotion Tracking', }), visState: - '{"title":"[eCommerce] Promotion Tracking","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(240,138,217,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*trouser*","label":"Revenue Trousers","value_template":"${{value}}"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(191,240,129,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*watch*","label":"Revenue Watches","value_template":"${{value}}"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(23,233,230,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*bag*","label":"Revenue Bags","value_template":"${{value}}"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(235,186,180,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*cocktail dress*","label":"Revenue Cocktail Dresses","value_template":"${{value}}"}],"time_field":"order_date","index_pattern":"kibana_sample_data_ecommerce","interval":">=12h","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","index_pattern":"kibana_sample_data_ecommerce","query_string":"taxful_total_price:>250","id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1}]},"aggs":[]}', + '{"title":"[eCommerce] Promotion Tracking","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(240,138,217,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*trouser*","label":"Revenue Trousers","value_template":"${{value}}"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(191,240,129,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*watch*","label":"Revenue Watches","value_template":"${{value}}"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(23,233,230,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*bag*","label":"Revenue Bags","value_template":"${{value}}"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(235,186,180,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*cocktail dress*","label":"Revenue Cocktail Dresses","value_template":"${{value}}"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":">=12h","use_kibana_indexes":true,"axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","index_pattern_ref_name":"ref_2_index_pattern","query_string":"taxful_total_price:>250","id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1}]},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -117,7 +117,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + ], }, { id: '10f1a240-b891-11e8-a6d9-e546fe2bba5f', @@ -152,7 +163,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Sold Products per Day', }), visState: - '{"title":"[eCommerce] Sold Products per Day","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day"}],"time_field":"order_date","index_pattern":"kibana_sample_data_ecommerce","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":10,"gauge_style":"half","filter":"","gauge_max":"300"},"aggs":[]}', + '{"title":"[eCommerce] Sold Products per Day","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":10,"gauge_style":"half","filter":"","gauge_max":"300","use_kibana_indexes":true},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -160,7 +171,13 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + }, + ], }, { id: '4b3ec120-b892-11e8-a6d9-e546fe2bba5f', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index b316835029d7c..f16c1c7104417 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -144,7 +144,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Delays & Cancellations', }), visState: - '{"title":"[Flights] Delays & Cancellations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(0,156,224,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"filter_ratio","numerator":"FlightDelay:true"}],"separate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":0.5,"stacked":"none","label":"Percent Delays"}],"time_field":"timestamp","index_pattern":"kibana_sample_data_flights","interval":">=1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"FlightDelay,Cancelled,Carrier","template":"{{Carrier}}: Flight Delayed and Cancelled!","index_pattern":"kibana_sample_data_flights","query_string":"FlightDelay:true AND Cancelled:true","id":"53b7dff0-4c89-11e8-a66a-6989ad5a0a39","color":"rgba(0,98,177,1)","time_field":"timestamp","icon":"fa-exclamation-triangle","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom"},"aggs":[]}', + '{"title":"[Flights] Delays & Cancellations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(0,156,224,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"filter_ratio","numerator":"FlightDelay:true"}],"separate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":0.5,"stacked":"none","label":"Percent Delays"}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","interval":">=1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"FlightDelay,Cancelled,Carrier","template":"{{Carrier}}: Flight Delayed and Cancelled!","index_pattern_ref_name":"ref_2_index_pattern","query_string":"FlightDelay:true AND Cancelled:true","id":"53b7dff0-4c89-11e8-a66a-6989ad5a0a39","color":"rgba(0,98,177,1)","time_field":"timestamp","icon":"fa-exclamation-triangle","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","use_kibana_indexes":true},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -152,7 +152,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d' + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: 'd3d7af60-4c81-11e8-b3d7-01146121b73d' + } + ] }, { id: '9886b410-4c8b-11e8-b3d7-01146121b73d', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts index 0396cb58d3692..8a3469fe4f3c0 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/saved_objects.ts @@ -89,7 +89,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Host, Visits and Bytes Table', }), visState: - '{"title":"[Logs] Host, Visits and Bytes Table","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"table","series":[{"id":"bd09d600-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum","field":"bytes"},{"sigma":"","id":"c9514c90-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum_bucket","field":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Total)"},{"id":"b7672c30-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"b7672c31-a6df-11e8-8b18-1da1dfc50975","type":"sum","field":"bytes"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Last Hour)"},{"id":"f2c20700-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"f2c20701-a6df-11e8-8b18-1da1dfc50975","type":"cardinality","field":"ip"},{"sigma":"","id":"f46333e0-a6df-11e8-8b18-1da1dfc50975","type":"sum_bucket","field":"f2c20701-a6df-11e8-8b18-1da1dfc50975"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Total)","color_rules":[{"value":1000,"id":"2e963080-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":1000,"id":"3d4fb880-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":1500,"id":"435f8a20-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1},{"id":"46fd7fc0-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"46fd7fc1-e5b1-11e7-bfc2-a1f7e71965a1","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Last Hour)","color_rules":[{"value":10,"id":"4e90aeb0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":10,"id":"6d59b1c0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":25,"id":"77578670-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1}],"time_field":"timestamp","index_pattern":"kibana_sample_data_logs","interval":"1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"bar_color_rules":[{"id":"e9b4e490-e1c6-11e7-b4f6-0f68c45f7387"}],"pivot_id":"extension.keyword","pivot_label":"Type","drilldown_url":"","axis_scale":"normal"},"aggs":[]}', + '{"title":"[Logs] Host, Visits and Bytes Table","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"table","series":[{"id":"bd09d600-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum","field":"bytes"},{"sigma":"","id":"c9514c90-e5b1-11e7-bfc2-a1f7e71965a1","type":"sum_bucket","field":"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Total)"},{"id":"b7672c30-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"b7672c31-a6df-11e8-8b18-1da1dfc50975","type":"sum","field":"bytes"}],"seperate_axis":0,"axis_position":"right","formatter":"bytes","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","color_rules":[{"id":"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1"}],"label":"Bytes (Last Hour)"},{"id":"f2c20700-a6df-11e8-8b18-1da1dfc50975","color":"#68BC00","split_mode":"everything","metrics":[{"id":"f2c20701-a6df-11e8-8b18-1da1dfc50975","type":"cardinality","field":"ip"},{"sigma":"","id":"f46333e0-a6df-11e8-8b18-1da1dfc50975","type":"sum_bucket","field":"f2c20701-a6df-11e8-8b18-1da1dfc50975"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Total)","color_rules":[{"value":1000,"id":"2e963080-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":1000,"id":"3d4fb880-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":1500,"id":"435f8a20-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1},{"id":"46fd7fc0-e5b1-11e7-bfc2-a1f7e71965a1","color":"#68BC00","split_mode":"everything","metrics":[{"id":"46fd7fc1-e5b1-11e7-bfc2-a1f7e71965a1","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Unique Visits (Last Hour)","color_rules":[{"value":10,"id":"4e90aeb0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(211,49,21,1)","operator":"lt"},{"value":10,"id":"6d59b1c0-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(252,196,0,1)","operator":"gte"},{"value":25,"id":"77578670-a6e0-11e8-8b18-1da1dfc50975","text":"rgba(104,188,0,1)","operator":"gte"}],"offset_time":"","value_template":"","trend_arrows":1}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","use_kibana_indexes": true,"interval":"1h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"bar_color_rules":[{"id":"e9b4e490-e1c6-11e7-b4f6-0f68c45f7387"}],"pivot_id":"extension.keyword","pivot_label":"Type","drilldown_url":"","axis_scale":"normal"},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -97,7 +97,13 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + ], }, { id: '69a34b00-9ee8-11e7-8711-e7a007dcef99', @@ -175,7 +181,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Logs] Response Codes Over Time + Annotations', }), visState: - '{"title":"[Logs] Response Codes Over Time + Annotations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(115,216,255,1)","split_mode":"terms","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":"0.5","stacked":"percent","terms_field":"response.keyword","terms_order_by":"61ca57f2-469d-11e7-af02-69e470af7417","label":"Response Code Count","split_color_mode":"gradient"}],"time_field":"timestamp","index_pattern":"kibana_sample_data_logs","interval":">=4h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"geo.src, host","template":"Security Error from {{geo.src}} on {{host}}","index_pattern":"kibana_sample_data_logs","query_string":"tags:error AND tags:security","id":"bd7548a0-2223-11e8-832f-d5027f3c8a47","color":"rgba(211,49,21,1)","time_field":"timestamp","icon":"fa-asterisk","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","axis_scale":"normal","drop_last_bucket":0},"aggs":[]}', + '{"title":"[Logs] Response Codes Over Time + Annotations","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(115,216,255,1)","split_mode":"terms","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"cardinality","field":"ip"}],"seperate_axis":0,"axis_position":"right","formatter":"percent","chart_type":"line","line_width":"2","point_size":"0","fill":"0.5","stacked":"percent","terms_field":"response.keyword","terms_order_by":"61ca57f2-469d-11e7-af02-69e470af7417","label":"Response Code Count","split_color_mode":"gradient"}],"time_field":"timestamp","index_pattern_ref_name":"ref_1_index_pattern","use_kibana_indexes":true,"interval":">=4h","axis_position":"left","axis_formatter":"number","show_legend":1,"show_grid":1,"annotations":[{"fields":"geo.src, host","template":"Security Error from {{geo.src}} on {{host}}","index_pattern_ref_name":"ref_2_index_pattern","query_string":"tags:error AND tags:security","id":"bd7548a0-2223-11e8-832f-d5027f3c8a47","color":"rgba(211,49,21,1)","time_field":"timestamp","icon":"fa-asterisk","ignore_global_filters":1,"ignore_panel_filters":1}],"legend_position":"bottom","axis_scale":"normal","drop_last_bucket":0},"aggs":[]}', uiStateJSON: '{}', description: '', version: 1, @@ -183,7 +189,18 @@ export const getSavedObjects = (): SavedObject[] => [ searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', }, }, - references: [], + references: [ + { + name: 'ref_1_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + { + name: 'ref_2_index_pattern', + type: 'index_pattern', + id: '90943e30-9a47-11e8-b64d-95841ca0b247', + }, + ], }, { id: '24a3e970-4257-11e8-b3aa-73fdaf54bfc9', diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx index 9ff26decc1c6e..633906feb785b 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/create_index_pattern_wizard.tsx @@ -57,10 +57,11 @@ export class CreateIndexPatternWizard extends Component< context.services.setBreadcrumbs(getCreateBreadcrumbs()); const type = new URLSearchParams(props.location.search).get('type') || undefined; + const indexPattern = new URLSearchParams(props.location.search).get('name') || ''; this.state = { step: 1, - indexPattern: '', + indexPattern, allIndices: [], remoteClustersExist: false, isInitiallyLoadingIndices: true, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 5959eb6aca4d4..41bb7c07bda7e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -412,4 +412,8 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableInspectEsQueries': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index fd63bb5bcaf43..c4a70f5065d8e 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -31,6 +31,7 @@ export interface UsageStats { 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; 'observability:enableAlertingExperience': boolean; + 'observability:enableInspectEsQueries': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 451b3ffe91535..ee96ae041dd09 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -8032,6 +8032,12 @@ "_meta": { "description": "Non-default value of setting." } + }, + "observability:enableInspectEsQueries": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } } } }, @@ -9327,6 +9333,16 @@ } } }, + "vis_type_timeseries": { + "properties": { + "timeseries_use_last_value_mode_total": { + "type": "long", + "_meta": { + "description": "Number of TSVB visualizations using \"last value\" as a time range" + } + } + } + }, "vis_type_vega": { "properties": { "vega_lib_specs_total": { diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts deleted file mode 100644 index c4da2085855e6..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.test.ts +++ /dev/null @@ -1,35 +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 { extractIndexPatterns } from './extract_index_patterns'; -import { PanelSchema } from './types'; - -describe('extractIndexPatterns(vis)', () => { - let panel: PanelSchema; - - beforeEach(() => { - panel = { - index_pattern: '*', - series: [ - { - override_index_pattern: 1, - series_index_pattern: 'example-1-*', - }, - { - override_index_pattern: 1, - series_index_pattern: 'example-2-*', - }, - ], - annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], - } as PanelSchema; - }); - - test('should return index patterns', () => { - expect(extractIndexPatterns(panel, '')).toEqual(['*', 'example-1-*', 'example-2-*', 'notes-*']); - }); -}); diff --git a/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts b/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts deleted file mode 100644 index c716ae7abb821..0000000000000 --- a/src/plugins/vis_type_timeseries/common/extract_index_patterns.ts +++ /dev/null @@ -1,43 +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 { uniq } from 'lodash'; -import { PanelSchema } from '../common/types'; - -export function extractIndexPatterns( - panel: PanelSchema, - defaultIndex?: PanelSchema['default_index_pattern'] -) { - const patterns: string[] = []; - - if (panel.index_pattern) { - patterns.push(panel.index_pattern); - } - - panel.series.forEach((series) => { - const indexPattern = series.series_index_pattern; - if (indexPattern && series.override_index_pattern) { - patterns.push(indexPattern); - } - }); - - if (panel.annotations) { - panel.annotations.forEach((item) => { - const indexPattern = item.index_pattern; - if (indexPattern) { - patterns.push(indexPattern); - } - }); - } - - if (patterns.length === 0 && defaultIndex) { - patterns.push(defaultIndex); - } - - return uniq(patterns).sort(); -} diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.test.ts b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts new file mode 100644 index 0000000000000..d1036aab2dc3e --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { toSanitizedFieldType } from './fields_utils'; +import type { FieldSpec, RuntimeField } from '../../data/common'; + +describe('fields_utils', () => { + describe('toSanitizedFieldType', () => { + const mockedField = { + lang: 'lang', + conflictDescriptions: {}, + aggregatable: true, + name: 'name', + type: 'type', + esTypes: ['long', 'geo'], + } as FieldSpec; + + test('should sanitize fields ', async () => { + const fields = [mockedField] as FieldSpec[]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` + Array [ + Object { + "label": "name", + "name": "name", + "type": "type", + }, + ] + `); + }); + + test('should filter runtime fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + runtimeField: {} as RuntimeField, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter non-aggregatable fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + aggregatable: false, + }, + ]; + + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + + test('should filter nested fields', async () => { + const fields: FieldSpec[] = [ + { + ...mockedField, + subType: { + nested: { + path: 'path', + }, + }, + }, + ]; + expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/fields_utils.ts b/src/plugins/vis_type_timeseries/common/fields_utils.ts new file mode 100644 index 0000000000000..04499d5320ab8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/fields_utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { FieldSpec } from '../../data/common'; +import { isNestedField } from '../../data/common'; +import { SanitizedFieldType } from './types'; + +export const toSanitizedFieldType = (fields: FieldSpec[]) => { + return fields + .filter( + (field) => + // Make sure to only include mapped fields, e.g. no index pattern runtime fields + !field.runtimeField && field.aggregatable && !isNestedField(field) + ) + .map( + (field) => + ({ + name: field.name, + label: field.customLabel ?? field.name, + type: field.type, + } as SanitizedFieldType) + ); +}; diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts new file mode 100644 index 0000000000000..515fadffb6b32 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + extractIndexPatternValues, + isStringTypeIndexPattern, + fetchIndexPattern, +} from './index_patterns_utils'; +import { PanelSchema } from './types'; +import { IndexPattern, IndexPatternsService } from '../../data/common'; + +describe('isStringTypeIndexPattern', () => { + test('should returns true on string-based index', () => { + expect(isStringTypeIndexPattern('index')).toBeTruthy(); + }); + test('should returns false on object-based index', () => { + expect(isStringTypeIndexPattern({ id: 'id' })).toBeFalsy(); + }); +}); + +describe('extractIndexPatterns', () => { + let panel: PanelSchema; + + beforeEach(() => { + panel = { + index_pattern: '*', + series: [ + { + override_index_pattern: 1, + series_index_pattern: 'example-1-*', + }, + { + override_index_pattern: 1, + series_index_pattern: 'example-2-*', + }, + ], + annotations: [{ index_pattern: 'notes-*' }, { index_pattern: 'example-1-*' }], + } as PanelSchema; + }); + + test('should return index patterns', () => { + expect(extractIndexPatternValues(panel, '')).toEqual([ + '*', + 'example-1-*', + 'example-2-*', + 'notes-*', + ]); + }); +}); + +describe('fetchIndexPattern', () => { + let mockedIndices: IndexPattern[] | []; + let indexPatternsService: IndexPatternsService; + + beforeEach(() => { + mockedIndices = []; + + indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + }); + + test('should return default index on no input value', async () => { + const value = await fetchIndexPattern('', indexPatternsService); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await fetchIndexPattern('indexTitle', indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await fetchIndexPattern({ id: 'indexId' }, indexPatternsService); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts new file mode 100644 index 0000000000000..398d1c30ed5a7 --- /dev/null +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts @@ -0,0 +1,80 @@ +/* + * 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 { uniq } from 'lodash'; +import { PanelSchema, IndexPatternValue, FetchedIndexPattern } from '../common/types'; +import { IndexPatternsService } from '../../data/common'; + +export const isStringTypeIndexPattern = ( + indexPatternValue: IndexPatternValue +): indexPatternValue is string => typeof indexPatternValue === 'string'; + +export const getIndexPatternKey = (indexPatternValue: IndexPatternValue) => + isStringTypeIndexPattern(indexPatternValue) ? indexPatternValue : indexPatternValue?.id ?? ''; + +export const extractIndexPatternValues = ( + panel: PanelSchema, + defaultIndex?: PanelSchema['default_index_pattern'] +) => { + const patterns: IndexPatternValue[] = []; + + if (panel.index_pattern) { + patterns.push(panel.index_pattern); + } + + panel.series.forEach((series) => { + const indexPattern = series.series_index_pattern; + if (indexPattern && series.override_index_pattern) { + patterns.push(indexPattern); + } + }); + + if (panel.annotations) { + panel.annotations.forEach((item) => { + const indexPattern = item.index_pattern; + if (indexPattern) { + patterns.push(indexPattern); + } + }); + } + + if (patterns.length === 0 && defaultIndex) { + patterns.push(defaultIndex); + } + + return uniq(patterns).sort(); +}; + +export const fetchIndexPattern = async ( + indexPatternValue: IndexPatternValue | undefined, + indexPatternsService: Pick +): Promise => { + let indexPattern: FetchedIndexPattern['indexPattern']; + let indexPatternString: string = ''; + + if (!indexPatternValue) { + indexPattern = await indexPatternsService.getDefault(); + } else { + if (isStringTypeIndexPattern(indexPatternValue)) { + indexPattern = (await indexPatternsService.find(indexPatternValue)).find( + (index) => index.title === indexPatternValue + ); + + if (!indexPattern) { + indexPatternString = indexPatternValue; + } + } else if (indexPatternValue.id) { + indexPattern = await indexPatternsService.get(indexPatternValue.id); + } + } + + return { + indexPattern, + indexPatternString: indexPattern?.title ?? indexPatternString, + }; +}; diff --git a/src/plugins/vis_type_timeseries/common/types.ts b/src/plugins/vis_type_timeseries/common/types.ts index 7d93232f310c9..1fe6196ad545b 100644 --- a/src/plugins/vis_type_timeseries/common/types.ts +++ b/src/plugins/vis_type_timeseries/common/types.ts @@ -13,10 +13,12 @@ import { seriesItems, visPayloadSchema, fieldObject, + indexPattern, annotationsItems, } from './vis_schema'; import { PANEL_TYPES } from './panel_types'; import { TimeseriesUIRestrictions } from './ui_restrictions'; +import { IndexPattern } from '../../data/common'; export type AnnotationItemsSchema = TypeOf; export type SeriesItemsSchema = TypeOf; @@ -24,6 +26,12 @@ export type MetricsItemsSchema = TypeOf; export type PanelSchema = TypeOf; export type VisPayload = TypeOf; export type FieldObject = TypeOf; +export type IndexPatternValue = TypeOf; + +export interface FetchedIndexPattern { + indexPattern: IndexPattern | undefined | null; + indexPatternString: string | undefined; +} export interface PanelData { id: string; diff --git a/src/plugins/vis_type_timeseries/common/vis_schema.ts b/src/plugins/vis_type_timeseries/common/vis_schema.ts index a6bf70948bc1b..297b021fa9e77 100644 --- a/src/plugins/vis_type_timeseries/common/vis_schema.ts +++ b/src/plugins/vis_type_timeseries/common/vis_schema.ts @@ -28,7 +28,7 @@ const numberOptional = schema.maybe(schema.number()); const queryObject = schema.object({ language: schema.string(), - query: schema.string(), + query: schema.oneOf([schema.string(), schema.any()]), }); const stringOrNumberOptionalNullable = schema.nullable( schema.oneOf([stringOptionalNullable, numberOptional]) @@ -37,6 +37,13 @@ const numberOptionalOrEmptyString = schema.maybe( schema.oneOf([numberOptional, schema.literal('')]) ); +export const indexPattern = schema.oneOf([ + schema.maybe(schema.string()), + schema.object({ + id: schema.string(), + }), +]); + export const fieldObject = stringOptionalNullable; export const annotationsItems = schema.object({ @@ -47,7 +54,7 @@ export const annotationsItems = schema.object({ id: schema.string(), ignore_global_filters: numberIntegerOptional, ignore_panel_filters: numberIntegerOptional, - index_pattern: stringOptionalNullable, + index_pattern: indexPattern, query_string: schema.maybe(queryObject), template: stringOptionalNullable, time_field: fieldObject, @@ -68,6 +75,7 @@ const gaugeColorRulesItems = schema.object({ operator: stringOptionalNullable, value: schema.maybe(schema.nullable(schema.number())), }); + export const metricsItems = schema.object({ field: fieldObject, id: stringRequired, @@ -167,7 +175,7 @@ export const seriesItems = schema.object({ point_size: numberOptionalOrEmptyString, separate_axis: numberIntegerOptional, seperate_axis: numberIntegerOptional, - series_index_pattern: stringOptionalNullable, + series_index_pattern: indexPattern, series_max_bars: numberIntegerOptional, series_time_field: fieldObject, series_interval: stringOptionalNullable, @@ -195,6 +203,7 @@ export const seriesItems = schema.object({ }); export const panel = schema.object({ + use_kibana_indexes: schema.maybe(schema.boolean()), annotations: schema.maybe(schema.arrayOf(annotationsItems)), axis_formatter: stringRequired, axis_position: stringRequired, @@ -218,7 +227,7 @@ export const panel = schema.object({ id: stringRequired, ignore_global_filters: numberOptional, ignore_global_filter: numberOptional, - index_pattern: stringRequired, + index_pattern: indexPattern, max_bars: numberIntegerOptional, interval: stringRequired, isModelInvalid: schema.maybe(schema.boolean()), diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index aa5eac84663ad..242b62a2c5ee4 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -5,5 +5,6 @@ "server": true, "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "visualize"], + "optionalPlugins": ["usageCollection"], "requiredBundles": ["kibanaUtils", "kibanaReact"] } diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx index 4fc7b89e23765..82989cc15d6c9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/field_select.tsx @@ -10,8 +10,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiComboBox, EuiComboBoxProps, EuiComboBoxOptionOption } from '@elastic/eui'; import { METRIC_TYPES } from '../../../../common/metric_types'; - -import type { SanitizedFieldType } from '../../../../common/types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; +import type { SanitizedFieldType, IndexPatternValue } from '../../../../common/types'; import type { TimeseriesUIRestrictions } from '../../../../common/ui_restrictions'; // @ts-ignore @@ -20,7 +20,7 @@ import { isFieldEnabled } from '../../lib/check_ui_restrictions'; interface FieldSelectProps { type: string; fields: Record; - indexPattern: string; + indexPattern: IndexPatternValue; value?: string | null; onChange: (options: Array>) => void; disabled?: boolean; @@ -62,8 +62,10 @@ export function FieldSelect({ const selectedOptions: Array> = []; let newPlaceholder = placeholder; + const fieldsSelector = getIndexPatternKey(indexPattern); + const groupedOptions: EuiComboBoxProps['options'] = Object.values( - (fields[indexPattern] || []).reduce>>( + (fields[fieldsSelector] || []).reduce>>( (acc, field) => { if (placeholder === field?.name) { newPlaceholder = field.label ?? field.name; diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js index f95eeb4816128..ab0db6daae18a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/annotations_editor.js @@ -32,8 +32,8 @@ import { EuiCode, EuiText, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { IndexPatternSelect } from './lib/index_pattern_select'; function newAnnotation() { return { @@ -91,7 +91,6 @@ export class AnnotationsEditor extends Component { const htmlId = htmlIdGenerator(model.id); const handleAdd = collectionActions.handleAdd.bind(null, this.props, newAnnotation); const handleDelete = collectionActions.handleDelete.bind(null, this.props, model); - const defaultIndexPattern = this.props.model.default_index_pattern; return (

@@ -108,30 +107,11 @@ export class AnnotationsEditor extends Component { - - } - helpText={ - defaultIndexPattern && - !model.index_pattern && - i18n.translate('visTypeTimeseries.annotationsEditor.searchByDefaultIndex', { - defaultMessage: 'Default index pattern is used. To query all indexes use *', - }) - } - fullWidth - > - - + { const config = getUISettings(); const timeFieldName = `${prefix}time_field`; @@ -89,13 +91,6 @@ export const IndexPattern = ({ const handleTextChange = createTextHandler(onChange); const timeRangeOptions = [ - { - label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.lastValue', { - defaultMessage: 'Last value', - }), - value: TIME_RANGE_DATA_MODES.LAST_VALUE, - disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.LAST_VALUE, uiRestrictions), - }, { label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.entireTimeRange', { defaultMessage: 'Entire time range', @@ -103,6 +98,13 @@ export const IndexPattern = ({ value: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, uiRestrictions), }, + { + label: i18n.translate('visTypeTimeseries.indexPattern.timeRange.lastValue', { + defaultMessage: 'Last value', + }), + value: TIME_RANGE_DATA_MODES.LAST_VALUE, + disabled: !isTimerangeModeEnabled(TIME_RANGE_DATA_MODES.LAST_VALUE, uiRestrictions), + }, ]; const defaults = { @@ -139,6 +141,7 @@ export const IndexPattern = ({ })} > - - - + - {allowLevelofDetail && ( + {allowLevelOfDetail && ( >; + +/** @internal **/ +type SelectedOptions = EuiComboBoxProps['selectedOptions']; + +const toComboBoxOptions = (options: IdsWithTitle) => + options.map(({ title, id }) => ({ label: title, id })); + +export const ComboBoxSelect = ({ + fetchedIndex, + onIndexChange, + onModeChange, + disabled, + placeholder, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [availableIndexes, setAvailableIndexes] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + + const onComboBoxChange: EuiComboBoxProps['onChange'] = useCallback( + ([selected]) => { + onIndexChange(selected ? { id: selected.id } : ''); + }, + [onIndexChange] + ); + + useEffect(() => { + let options: SelectedOptions = []; + const { indexPattern, indexPatternString } = fetchedIndex; + + if (indexPattern || indexPatternString) { + if (!indexPattern) { + options = [{ label: indexPatternString ?? '' }]; + } else { + options = [ + { + id: indexPattern.id, + label: indexPattern.title, + }, + ]; + } + } + setSelectedOptions(options); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndexes() { + setAvailableIndexes(await getDataStart().indexPatterns.getIdsWithTitle()); + } + + fetchIndexes(); + }, []); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx new file mode 100644 index 0000000000000..86d1758932301 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/field_text_select.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { useCallback, useState, useEffect } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import { EuiFieldText, EuiFieldTextProps } from '@elastic/eui'; +import { SwitchModePopover } from './switch_mode_popover'; + +import type { SelectIndexComponentProps } from './types'; + +export const FieldTextSelect = ({ + fetchedIndex, + onIndexChange, + disabled, + placeholder, + onModeChange, + allowSwitchMode, + 'data-test-subj': dataTestSubj, +}: SelectIndexComponentProps) => { + const [inputValue, setInputValue] = useState(); + const { indexPatternString } = fetchedIndex; + + const onFieldTextChange: EuiFieldTextProps['onChange'] = useCallback((e) => { + setInputValue(e.target.value); + }, []); + + useEffect(() => { + if (inputValue === undefined) { + setInputValue(indexPatternString ?? ''); + } + }, [indexPatternString, inputValue]); + + useDebounce( + () => { + if (inputValue !== indexPatternString) { + onIndexChange(inputValue); + } + }, + 150, + [inputValue, onIndexChange] + ); + + return ( + + ), + })} + /> + ); +}; diff --git a/src/plugins/vis_type_timeseries/common/field_types.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts similarity index 73% rename from src/plugins/vis_type_timeseries/common/field_types.ts rename to src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts index f9ebc83b4a5db..584f13e7a025b 100644 --- a/src/plugins/vis_type_timeseries/common/field_types.ts +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts @@ -6,10 +6,4 @@ * Side Public License, v 1. */ -export enum FIELD_TYPES { - BOOLEAN = 'boolean', - DATE = 'date', - GEO = 'geo_point', - NUMBER = 'number', - STRING = 'string', -} +export { IndexPatternSelect } from './index_pattern_select'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx new file mode 100644 index 0000000000000..28b9c173a2b1b --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -0,0 +1,154 @@ +/* + * 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, { useState, useContext, useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiFormRow, EuiText, EuiLink, htmlIdGenerator } from '@elastic/eui'; +import { getCoreStart, getDataStart } from '../../../../services'; +import { PanelModelContext } from '../../../contexts/panel_model_context'; + +import { + isStringTypeIndexPattern, + fetchIndexPattern, +} from '../../../../../common/index_patterns_utils'; + +import { FieldTextSelect } from './field_text_select'; +import { ComboBoxSelect } from './combo_box_select'; + +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; + +const USE_KIBANA_INDEXES_KEY = 'use_kibana_indexes'; + +interface IndexPatternSelectProps { + value: IndexPatternValue; + indexPatternName: string; + onChange: Function; + disabled?: boolean; + allowIndexSwitchingMode?: boolean; +} + +const defaultIndexPatternHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.defaultIndexPatternText', + { + defaultMessage: 'Default index pattern is used.', + } +); + +const queryAllIndexesHelpText = i18n.translate( + 'visTypeTimeseries.indexPatternSelect.queryAllIndexesText', + { + defaultMessage: 'To query all indexes use *', + } +); + +const indexPatternLabel = i18n.translate('visTypeTimeseries.indexPatternSelect.label', { + defaultMessage: 'Index pattern', +}); + +export const IndexPatternSelect = ({ + value, + indexPatternName, + onChange, + disabled, + allowIndexSwitchingMode, +}: IndexPatternSelectProps) => { + const htmlId = htmlIdGenerator(); + const panelModel = useContext(PanelModelContext); + const [fetchedIndex, setFetchedIndex] = useState(); + const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); + const Component = useKibanaIndices ? ComboBoxSelect : FieldTextSelect; + + const onIndexChange = useCallback( + (index: IndexPatternValue) => { + onChange({ + [indexPatternName]: index, + }); + }, + [indexPatternName, onChange] + ); + + const onModeChange = useCallback( + (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => { + onChange({ + [USE_KIBANA_INDEXES_KEY]: useKibanaIndexes, + [indexPatternName]: index?.indexPattern?.id + ? { + id: index.indexPattern.id, + } + : '', + }); + }, + [onChange, indexPatternName] + ); + + const navigateToCreateIndexPatternPage = useCallback(() => { + const coreStart = getCoreStart(); + + coreStart.application.navigateToApp('management', { + path: `/kibana/indexPatterns/create?name=${fetchedIndex!.indexPatternString ?? ''}`, + }); + }, [fetchedIndex]); + + useEffect(() => { + async function fetchIndex() { + const { indexPatterns } = getDataStart(); + + setFetchedIndex( + value + ? await fetchIndexPattern(value, indexPatterns) + : { + indexPattern: undefined, + indexPatternString: undefined, + } + ); + } + + fetchIndex(); + }, [value]); + + if (!fetchedIndex) { + return null; + } + + return ( + + + + + + ) : null + } + > + + + ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx new file mode 100644 index 0000000000000..5f5506ce4a332 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/switch_mode_popover.tsx @@ -0,0 +1,80 @@ +/* + * 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, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonIcon, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; + +import type { PopoverProps } from './types'; + +export const SwitchModePopover = ({ onModeChange, useKibanaIndices }: PopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onButtonClick = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []); + + const switchMode = useCallback(() => { + onModeChange(!useKibanaIndices); + }, [onModeChange, useKibanaIndices]); + + return ( + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + style={{ height: 'auto' }} + > +
+ + {i18n.translate('visTypeTimeseries.indexPatternSelect.switchModePopover.title', { + defaultMessage: 'Index pattern selection mode', + })} + + + + + + +
+
+ ); +}; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts new file mode 100644 index 0000000000000..93b15402e3c24 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Assign } from '@kbn/utility-types'; +import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; + +/** @internal **/ +export interface SelectIndexComponentProps { + fetchedIndex: FetchedIndexPattern; + onIndexChange: (value: IndexPatternValue) => void; + onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; + 'data-test-subj': string; + placeholder?: string; + disabled?: boolean; + allowSwitchMode?: boolean; +} + +/** @internal **/ +export type PopoverProps = Assign< + Pick, + { + useKibanaIndices: boolean; + } +>; diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx index e302bbb9adb0b..f39ff6923f5ce 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.tsx @@ -29,12 +29,11 @@ import type { Writable } from '@kbn/utility-types'; // @ts-ignore import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { createSelectHandler } from '../lib/create_select_handler'; import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { YesNo } from '../yes_no'; @@ -128,6 +127,7 @@ export class GaugePanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -149,10 +149,10 @@ export class GaugePanelConfig extends Component< language: model.filter?.language || getDefaultQueryLanguage(), query: model.filter?.query || '', }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
@@ -321,6 +321,7 @@ export class GaugePanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="gaugeEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="gaugeEditorPanelOptionsBtn" > @@ -161,13 +161,13 @@ export class MarkdownPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} />
diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx index ec11f94d245a0..3ab49c1bef873 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/metric.tsx @@ -25,12 +25,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; // @ts-expect-error import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { ColorRules } from '../color_rules'; import { YesNo } from '../yes_no'; - -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { limitOfSeries } from '../../../../common/ui_restrictions'; @@ -93,6 +91,7 @@ export class MetricPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -111,13 +110,13 @@ export class MetricPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -166,6 +165,7 @@ export class MetricPanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="metricEditorDataBtn" > - -
- -
-
+ + +
+ +
+
+
); } diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 20e07be4e3fa4..f3d01df19666a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -31,16 +31,17 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FieldSelect } from '../aggs/field_select'; // @ts-expect-error not typed yet import { SeriesEditor } from '../series_editor'; -// @ts-ignore should be typed after https://github.com/elastic/kibana/pull/92812 to reduce conflicts +// @ts-expect-error not typed yet import { IndexPattern } from '../index_pattern'; import { YesNo } from '../yes_no'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging + import { QueryBarWrapper } from '../query_bar_wrapper'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; import { VisDataContext } from '../../contexts/vis_data_context'; import { BUCKET_TYPES } from '../../../../common/metric_types'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export class TablePanelConfig extends Component< PanelConfigProps, @@ -66,7 +67,7 @@ export class TablePanelConfig extends Component< handlePivotChange = (selectedOption: Array>) => { const { fields, model } = this.props; const pivotId = get(selectedOption, '[0].value', null); - const field = fields[model.index_pattern].find((f) => f.name === pivotId); + const field = fields[getIndexPatternKey(model.index_pattern)].find((f) => f.name === pivotId); const pivotType = get(field, 'type', model.pivot_type); this.props.onChange({ @@ -237,15 +238,13 @@ export class TablePanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -274,6 +273,7 @@ export class TablePanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="tableEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="tableEditorPanelOptionsBtn" > - @@ -202,13 +201,13 @@ export class TimeseriesPanelConfig extends Component< > { + this.props.onChange({ filter }); }} - onChange={(filter: PanelConfigProps['model']['filter']) => - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx index 184063f88ef03..78ac11eb39744 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/top_n.tsx @@ -33,7 +33,6 @@ import { ColorRules } from '../color_rules'; import { ColorPicker } from '../color_picker'; import { YesNo } from '../yes_no'; import { getDefaultQueryLanguage } from '../lib/get_default_query_language'; -// @ts-ignore this is typed in https://github.com/elastic/kibana/pull/92812, remove ignore after merging import { QueryBarWrapper } from '../query_bar_wrapper'; import { PanelConfigProps, PANEL_CONFIG_TABS } from './types'; import { TimeseriesVisParams } from '../../../types'; @@ -120,6 +119,7 @@ export class TopNPanelConfig extends Component< fields={this.props.fields} model={this.props.model} onChange={this.props.onChange} + allowIndexSwitchingMode={true} /> @@ -138,13 +138,13 @@ export class TopNPanelConfig extends Component< > - this.props.onChange({ filter }) - } - indexPatterns={[model.index_pattern || model.default_index_pattern]} + onChange={(filter: PanelConfigProps['model']['filter']) => { + this.props.onChange({ filter }); + }} + indexPatterns={[model.index_pattern || model.default_index_pattern || '']} /> @@ -225,6 +225,7 @@ export class TopNPanelConfig extends Component< this.switchTab(PANEL_CONFIG_TABS.DATA)} + data-test-subj="topNEditorDataBtn" > this.switchTab(PANEL_CONFIG_TABS.OPTIONS)} + data-test-subj="topNEditorPanelOptionsBtn" > & { + indexPatterns: IndexPatternValue[]; +}; + +export function QueryBarWrapper({ query, onChange, indexPatterns }: QueryBarWrapperProps) { + const { indexPatterns: indexPatternsService } = getDataStart(); + const [indexes, setIndexes] = useState([]); + + const coreStartContext = useContext(CoreStartContext); + + useEffect(() => { + async function fetchIndexes() { + const i: QueryStringInputProps['indexPatterns'] = []; + + for (const index of indexPatterns ?? []) { + if (isStringTypeIndexPattern(index)) { + i.push(index); + } else if (index?.id) { + const fetchedIndex = await fetchIndexPattern(index, indexPatternsService); + + if (fetchedIndex.indexPattern) { + i.push(fetchedIndex.indexPattern); + } + } + } + setIndexes(i); + } + + fetchIndexes(); + }, [indexPatterns, indexPatternsService]); + + return ( + + ); +} diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config.js b/src/plugins/vis_type_timeseries/public/application/components/series_config.js index 4e48ed4406ea5..3185503acb569 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config.js @@ -137,5 +137,5 @@ SeriesConfig.propTypes = { panel: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js index 0b67d52c23cd2..950101103b3a5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js +++ b/src/plugins/vis_type_timeseries/public/application/components/series_config_query_bar_with_ignore_global_filter.js @@ -90,5 +90,5 @@ SeriesConfigQueryBarWithIgnoreGlobalFilter.propTypes = { onChange: PropTypes.func, model: PropTypes.object, panel: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js index 5891320aa684f..b996abd6373ab 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js @@ -25,7 +25,7 @@ import { EuiFieldText, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; -import { FIELD_TYPES } from '../../../../common/field_types'; +import { KBN_FIELD_TYPES } from '../../../../../data/public'; import { STACKED_OPTIONS } from '../../visualizations/constants'; const DEFAULTS = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' }; @@ -133,7 +133,7 @@ export const SplitByTermsUI = ({ - {selectedFieldType === FIELD_TYPES.STRING && ( + {selectedFieldType === KBN_FIELD_TYPES.STRING && ( { + abortableFetchFields = (extractedIndexPatterns: IndexPatternValue[]) => { this.abortControllerFetchFields?.abort(); this.abortControllerFetchFields = new AbortController(); @@ -202,7 +213,7 @@ export class VisEditor extends Component { const defaultIndexTitle = index?.title ?? ''; - const indexPatterns = extractIndexPatterns(this.props.vis.params, defaultIndexTitle); + const indexPatterns = extractIndexPatternValues(this.props.vis.params, defaultIndexTitle); const visFields = await fetchFields(indexPatterns); this.setState((state) => ({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js index 2909167031d08..46cc8b6ebe635 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/gauge/series.js @@ -198,7 +198,7 @@ GaugeSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const GaugeSeries = injectI18n(GaugeSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js index 6f00abe5aa2c0..f9817242a101a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/markdown/series.js @@ -200,7 +200,7 @@ MarkdownSeriesUi.propTypes = { visible: PropTypes.bool, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MarkdownSeries = injectI18n(MarkdownSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js index 64425cf534226..5ec2378792812 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/metric/series.js @@ -211,7 +211,7 @@ MetricSeriesUi.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const MetricSeries = injectI18n(MetricSeriesUi); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js index fecd6cde1dca8..0ba8d3e855365 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/config.js @@ -9,6 +9,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; + import { DataFormatPicker } from '../../data_format_picker'; import { createSelectHandler } from '../../lib/create_select_handler'; import { createTextHandler } from '../../lib/create_text_handler'; @@ -28,11 +30,11 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { getDefaultQueryLanguage } from '../../lib/get_default_query_language'; - import { QueryBarWrapper } from '../../query_bar_wrapper'; -class TableSeriesConfigUI extends Component { + +export class TableSeriesConfig extends Component { UNSAFE_componentWillMount() { const { model } = this.props; if (!model.color_rules || (model.color_rules && model.color_rules.length === 0)) { @@ -48,68 +50,58 @@ class TableSeriesConfigUI extends Component { const handleSelectChange = createSelectHandler(this.props.onChange); const handleTextChange = createTextHandler(this.props.onChange); const htmlId = htmlIdGenerator(); - const { intl } = this.props; const functionOptions = [ { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.sumLabel', + label: i18n.translate('visTypeTimeseries.table.sumLabel', { defaultMessage: 'Sum', }), value: 'sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.maxLabel', + label: i18n.translate('visTypeTimeseries.table.maxLabel', { defaultMessage: 'Max', }), value: 'max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.minLabel', + label: i18n.translate('visTypeTimeseries.table.minLabel', { defaultMessage: 'Min', }), value: 'min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.avgLabel', + label: i18n.translate('visTypeTimeseries.table.avgLabel', { defaultMessage: 'Avg', }), value: 'mean', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallSumLabel', + label: i18n.translate('visTypeTimeseries.table.overallSumLabel', { defaultMessage: 'Overall Sum', }), value: 'overall_sum', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMaxLabel', + label: i18n.translate('visTypeTimeseries.table.overallMaxLabel', { defaultMessage: 'Overall Max', }), value: 'overall_max', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallMinLabel', + label: i18n.translate('visTypeTimeseries.table.overallMinLabel', { defaultMessage: 'Overall Min', }), value: 'overall_min', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.overallAvgLabel', + label: i18n.translate('visTypeTimeseries.table.overallAvgLabel', { defaultMessage: 'Overall Avg', }), value: 'overall_avg', }, { - label: intl.formatMessage({ - id: 'visTypeTimeseries.table.cumulativeSumLabel', + label: i18n.translate('visTypeTimeseries.table.cumulativeSumLabel', { defaultMessage: 'Cumulative Sum', }), value: 'cumulative_sum', @@ -170,11 +162,8 @@ class TableSeriesConfigUI extends Component { > this.props.onChange({ filter })} indexPatterns={[this.props.indexPatternForQuery]} @@ -259,11 +248,9 @@ class TableSeriesConfigUI extends Component { } } -TableSeriesConfigUI.propTypes = { +TableSeriesConfig.propTypes = { fields: PropTypes.object, model: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; - -export const TableSeriesConfig = injectI18n(TableSeriesConfigUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js index a56afd1f817b3..acd2f4cc17d4a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/series.js @@ -186,7 +186,7 @@ TableSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; export const TableSeries = injectI18n(TableSeriesUI); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index 3df12dafd5a66..22bf2fa4ca708 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -542,7 +542,7 @@ export const TimeseriesConfig = injectI18n(function (props) { {...props} prefix="series_" disabled={!model.override_index_pattern} - allowLevelofDetail={true} + allowLevelOfDetail={true} /> @@ -555,6 +555,6 @@ TimeseriesConfig.propTypes = { model: PropTypes.object, panel: PropTypes.object, onChange: PropTypes.func, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js index 76df07ce7c8c4..bb10ac57c5ae9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/series.js @@ -209,7 +209,7 @@ TimeseriesSeriesUI.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), seriesQuantity: PropTypes.object, }; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js index bfe446a8226e8..61bb7e2473dd9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/top_n/series.js @@ -200,5 +200,5 @@ TopNSeries.propTypes = { togglePanelActivation: PropTypes.func, uiRestrictions: PropTypes.object, dragHandleProps: PropTypes.object, - indexPatternForQuery: PropTypes.string, + indexPatternForQuery: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), }; diff --git a/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts new file mode 100644 index 0000000000000..534f686ca13fc --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/contexts/panel_model_context.ts @@ -0,0 +1,12 @@ +/* + * 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 { PanelSchema } from '../../../common/types'; + +export const PanelModelContext = React.createContext(null); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts index 088930f90a765..af3ddd643cac8 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts @@ -9,12 +9,14 @@ import { i18n } from '@kbn/i18n'; import { getCoreStart, getDataStart } from '../../services'; import { ROUTES } from '../../../common/constants'; -import { SanitizedFieldType } from '../../../common/types'; +import { SanitizedFieldType, IndexPatternValue } from '../../../common/types'; +import { getIndexPatternKey } from '../../../common/index_patterns_utils'; +import { toSanitizedFieldType } from '../../../common/fields_utils'; export type VisFields = Record; export async function fetchFields( - indexes: string[] = [], + indexes: IndexPatternValue[] = [], signal?: AbortSignal ): Promise { const patterns = Array.isArray(indexes) ? indexes : [indexes]; @@ -25,26 +27,33 @@ export async function fetchFields( const defaultIndexPattern = await dataStart.indexPatterns.getDefault(); const indexFields = await Promise.all( patterns.map(async (pattern) => { - return coreStart.http.get(ROUTES.FIELDS, { - query: { - index: pattern, - }, - signal, - }); + if (typeof pattern !== 'string' && pattern?.id) { + return toSanitizedFieldType( + (await dataStart.indexPatterns.get(pattern.id)).getNonScriptedFields() + ); + } else { + return coreStart.http.get(ROUTES.FIELDS, { + query: { + index: `${pattern ?? ''}`, + }, + signal, + }); + } }) ); const fields: VisFields = patterns.reduce( (cumulatedFields, currentPattern, index) => ({ ...cumulatedFields, - [currentPattern]: indexFields[index], + [getIndexPatternKey(currentPattern)]: indexFields[index], }), {} ); - if (defaultIndexPattern?.title && patterns.includes(defaultIndexPattern.title)) { - fields[''] = fields[defaultIndexPattern.title]; + if (defaultIndexPattern) { + fields[''] = toSanitizedFieldType(await defaultIndexPattern.getNonScriptedFields()); } + return fields; } catch (error) { if (error.name !== 'AbortError') { diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 9e996fcc74833..5d5e082b2b7bb 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { TSVB_EDITOR_NAME } from './application'; import { PANEL_TYPES } from '../common/panel_types'; +import { isStringTypeIndexPattern } from '../common/index_patterns_utils'; import { toExpressionAst } from './to_ast'; import { VIS_EVENT_TO_TRIGGER, VisGroups, VisParams } from '../../visualizations/public'; import { getDataStart } from './services'; @@ -53,6 +54,7 @@ export const metricsVisDefinition = { ], time_field: '', index_pattern: '', + use_kibana_indexes: true, interval: '', axis_position: 'left', axis_formatter: 'number', @@ -77,7 +79,20 @@ export const metricsVisDefinition = { inspectorAdapters: {}, getUsedIndexPattern: async (params: VisParams) => { const { indexPatterns } = getDataStart(); + const indexPatternValue = params.index_pattern; - return params.index_pattern ? await indexPatterns.find(params.index_pattern) : []; + if (indexPatternValue) { + if (isStringTypeIndexPattern(indexPatternValue)) { + return await indexPatterns.find(indexPatternValue); + } + + if (indexPatternValue.id) { + return [await indexPatterns.get(indexPatternValue.id)]; + } + } + + const defaultIndex = await indexPatterns.getDefault(); + + return defaultIndex ? [defaultIndex] : []; }, }; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index f1bc5a11550e9..b0e85f8e44fbe 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -10,6 +10,7 @@ import { uniqBy } from 'lodash'; import { Framework } from '../plugin'; import { VisTypeTimeseriesFieldsRequest, VisTypeTimeseriesRequestHandlerContext } from '../types'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getFields( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -17,26 +18,29 @@ export async function getFields( framework: Framework, indexPatternString: string ) { + const indexPatternsService = await framework.getIndexPatternsService(requestContext); + const cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + if (!indexPatternString) { - const indexPatternsService = await framework.getIndexPatternsService(requestContext); const defaultIndexPattern = await indexPatternsService.getDefault(); indexPatternString = defaultIndexPattern?.title ?? ''; } + const fetchedIndex = await cachedIndexPatternFetcher(indexPatternString); + const { searchStrategy, capabilities, } = (await framework.searchStrategyRegistry.getViableStrategy( requestContext, request, - indexPatternString + fetchedIndex ))!; const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - request, - indexPatternString, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index 0ad50a296b481..d91104fb299d7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -19,6 +19,7 @@ import type { import { getSeriesData } from './vis_data/get_series_data'; import { getTableData } from './vis_data/get_table_data'; import { getEsQueryConfig } from './vis_data/helpers/get_es_query_uisettings'; +import { getCachedIndexPatternFetcher } from './search_strategies/lib/cached_index_pattern_fetcher'; export async function getVisData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -29,12 +30,14 @@ export async function getVisData( const esShardTimeout = await framework.getEsShardTimeout(); const indexPatternsService = await framework.getIndexPatternsService(requestContext); const esQueryConfig = await getEsQueryConfig(uiSettings); + const services: VisTypeTimeseriesRequestServices = { esQueryConfig, esShardTimeout, indexPatternsService, uiSettings, searchStrategyRegistry: framework.searchStrategyRegistry, + cachedIndexPatternFetcher: getCachedIndexPatternFetcher(indexPatternsService), }; const promises = request.body.panels.map((panel) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts new file mode 100644 index 0000000000000..aeaf3ca2cd327 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { IndexPattern, IndexPatternsService } from 'src/plugins/data/server'; +import { + getCachedIndexPatternFetcher, + CachedIndexPatternFetcher, +} from './cached_index_pattern_fetcher'; + +describe('CachedIndexPatternFetcher', () => { + let mockedIndices: IndexPattern[] | []; + let cachedIndexPatternFetcher: CachedIndexPatternFetcher; + + beforeEach(() => { + mockedIndices = []; + + const indexPatternsService = ({ + getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), + get: jest.fn(() => Promise.resolve(mockedIndices[0])), + find: jest.fn(() => Promise.resolve(mockedIndices || [])), + } as unknown) as IndexPatternsService; + + cachedIndexPatternFetcher = getCachedIndexPatternFetcher(indexPatternsService); + }); + + test('should return default index on no input value', async () => { + const value = await cachedIndexPatternFetcher(''); + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "default", + "title": "index", + }, + "indexPatternString": "index", + } + `); + }); + + describe('text-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return only indexPatternString if Kibana index does not exist', async () => { + const value = await cachedIndexPatternFetcher('indexTitle'); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "indexTitle", + } + `); + }); + }); + + describe('object-based index', () => { + test('should return the Kibana index if it exists', async () => { + mockedIndices = [ + { + id: 'indexId', + title: 'indexTitle', + }, + ] as IndexPattern[]; + + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": Object { + "id": "indexId", + "title": "indexTitle", + }, + "indexPatternString": "indexTitle", + } + `); + }); + + test('should return default index if Kibana index not found', async () => { + const value = await cachedIndexPatternFetcher({ id: 'indexId' }); + + expect(value).toMatchInlineSnapshot(` + Object { + "indexPattern": undefined, + "indexPatternString": "", + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts new file mode 100644 index 0000000000000..68cbd93cdc614 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/cached_index_pattern_fetcher.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getIndexPatternKey, fetchIndexPattern } from '../../../../common/index_patterns_utils'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { IndexPatternValue, FetchedIndexPattern } from '../../../../common/types'; + +export const getCachedIndexPatternFetcher = (indexPatternsService: IndexPatternsService) => { + const cache = new Map(); + + return async (indexPatternValue: IndexPatternValue): Promise => { + const key = getIndexPatternKey(indexPatternValue); + + if (cache.has(key)) { + return cache.get(key); + } + + const fetchedIndex = fetchIndexPattern(indexPatternValue, indexPatternsService); + + cache.set(indexPatternValue, fetchedIndex); + + return fetchedIndex; + }; +}; + +export type CachedIndexPatternFetcher = ReturnType; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts similarity index 57% rename from src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts index f95667612efa4..9003eb7fc2ced 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/fields_fetcher.ts @@ -6,21 +6,26 @@ * Side Public License, v 1. */ -import { - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../../types'; -import { AbstractSearchStrategy, DefaultSearchCapabilities } from '../../search_strategies'; +import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; +import type { AbstractSearchStrategy, DefaultSearchCapabilities } from '../index'; +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from './cached_index_pattern_fetcher'; export interface FieldsFetcherServices { - requestContext: VisTypeTimeseriesRequestHandlerContext; + indexPatternsService: IndexPatternsService; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; searchStrategy: AbstractSearchStrategy; capabilities: DefaultSearchCapabilities; } export const createFieldsFetcher = ( req: VisTypeTimeseriesVisDataRequest, - { capabilities, requestContext, searchStrategy }: FieldsFetcherServices + { + capabilities, + indexPatternsService, + searchStrategy, + cachedIndexPatternFetcher, + }: FieldsFetcherServices ) => { const fieldsCacheMap = new Map(); @@ -28,11 +33,11 @@ export const createFieldsFetcher = ( if (fieldsCacheMap.has(index)) { return fieldsCacheMap.get(index); } + const fetchedIndex = await cachedIndexPatternFetcher(index); const fields = await searchStrategy.getFieldsForWildcard( - requestContext, - req, - index, + fetchedIndex, + indexPatternsService, capabilities ); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts deleted file mode 100644 index 512494de290fd..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/get_index_pattern.ts +++ /dev/null @@ -1,32 +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 { IndexPatternsService, IndexPattern } from '../../../../../data/server'; - -interface IndexPatternObjectDependencies { - indexPatternsService: IndexPatternsService; -} -export async function getIndexPatternObject( - indexPatternString: string, - { indexPatternsService }: IndexPatternObjectDependencies -) { - let indexPatternObject: IndexPattern | undefined | null; - - if (!indexPatternString) { - indexPatternObject = await indexPatternsService.getDefault(); - } else { - indexPatternObject = (await indexPatternsService.find(indexPatternString)).find( - (index) => index.title === indexPatternString - ); - } - - return { - indexPatternObject, - indexPatternString: indexPatternObject?.title || indexPatternString || '', - }; -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index f9a49bc322a29..a6e7c5b11ee64 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -10,29 +10,27 @@ import { get } from 'lodash'; import { SearchStrategyRegistry } from './search_strategy_registry'; import { AbstractSearchStrategy, DefaultSearchStrategy } from './strategies'; import { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; -import { Framework } from '../../plugin'; import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; const getPrivateField = (registry: SearchStrategyRegistry, field: string) => get(registry, field) as T; class MockSearchStrategy extends AbstractSearchStrategy { - checkForViability() { - return Promise.resolve({ + async checkForViability() { + return { isViable: true, capabilities: {}, - }); + }; } } describe('SearchStrategyRegister', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let registry: SearchStrategyRegistry; beforeAll(() => { registry = new SearchStrategyRegistry(); - registry.addStrategy(new DefaultSearchStrategy(framework)); + registry.addStrategy(new DefaultSearchStrategy()); }); test('should init strategies register', () => { @@ -47,12 +45,11 @@ describe('SearchStrategyRegister', () => { test('should return a DefaultSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof DefaultSearchStrategy).toBe(true); @@ -60,7 +57,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -69,14 +66,13 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {} as VisTypeTimeseriesRequest; - const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy(framework); + const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy( requestContext, req, - indexPattern + { indexPatternString: '*', indexPattern: undefined } ))!; expect(searchStrategy instanceof MockSearchStrategy).toBe(true); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts index 11ff4b0a8a51f..4a013fd89735d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts @@ -6,14 +6,10 @@ * Side Public License, v 1. */ -import { extractIndexPatterns } from '../../../common/extract_index_patterns'; -import { PanelSchema } from '../../../common/types'; -import { - VisTypeTimeseriesRequest, - VisTypeTimeseriesRequestHandlerContext, - VisTypeTimeseriesVisDataRequest, -} from '../../types'; +import { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext } from '../../types'; import { AbstractSearchStrategy } from './strategies'; +import { FetchedIndexPattern } from '../../../common/types'; + export class SearchStrategyRegistry { private strategies: AbstractSearchStrategy[] = []; @@ -27,13 +23,13 @@ export class SearchStrategyRegistry { async getViableStrategy( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ) { for (const searchStrategy of this.strategies) { const { isViable, capabilities } = await searchStrategy.checkForViability( requestContext, req, - indexPattern + fetchedIndexPattern ); if (isViable) { @@ -44,14 +40,4 @@ export class SearchStrategyRegistry { } } } - - async getViableStrategyForPanel( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesVisDataRequest, - panel: PanelSchema - ) { - const indexPattern = extractIndexPatterns(panel, panel.default_index_pattern).join(','); - - return this.getViableStrategy(requestContext, req, indexPattern); - } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index e7282eba58ec7..fb66e32447c22 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -6,48 +6,26 @@ * Side Public License, v 1. */ -const mockGetFieldsForWildcard = jest.fn(() => []); - -jest.mock('../../../../../data/server', () => ({ - indexPatterns: { - isNestedField: jest.fn(() => false), - }, - IndexPatternsFetcher: jest.fn().mockImplementation(() => ({ - getFieldsForWildcard: mockGetFieldsForWildcard, - })), -})); +import { IndexPatternsService } from '../../../../../data/common'; import { from } from 'rxjs'; -import { AbstractSearchStrategy, toSanitizedFieldType } from './abstract_search_strategy'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; import type { IFieldType } from '../../../../../data/common'; -import type { FieldSpec, RuntimeField } from '../../../../../data/common'; -import { - VisTypeTimeseriesRequest, +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { Framework } from '../../../plugin'; -import { indexPatterns } from '../../../../../data/server'; class FooSearchStrategy extends AbstractSearchStrategy {} describe('AbstractSearchStrategy', () => { let abstractSearchStrategy: AbstractSearchStrategy; let mockedFields: IFieldType[]; - let indexPattern: string; let requestContext: VisTypeTimeseriesRequestHandlerContext; - let framework: Framework; beforeEach(() => { mockedFields = []; - framework = ({ - getIndexPatternsService: jest.fn(() => - Promise.resolve({ - find: jest.fn(() => []), - getDefault: jest.fn(() => {}), - }) - ), - } as unknown) as Framework; requestContext = ({ core: { elasticsearch: { @@ -60,7 +38,7 @@ describe('AbstractSearchStrategy', () => { search: jest.fn().mockReturnValue(from(Promise.resolve({}))), }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - abstractSearchStrategy = new FooSearchStrategy(framework); + abstractSearchStrategy = new FooSearchStrategy(); }); test('should init an AbstractSearchStrategy instance', () => { @@ -71,17 +49,15 @@ describe('AbstractSearchStrategy', () => { test('should return fields for wildcard', async () => { const fields = await abstractSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesRequest, - indexPattern + { indexPatternString: '', indexPattern: undefined }, + ({ + getDefault: jest.fn(), + getFieldsForWildcard: jest.fn(() => Promise.resolve(mockedFields)), + } as unknown) as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher ); expect(fields).toEqual(mockedFields); - expect(mockGetFieldsForWildcard).toHaveBeenCalledWith({ - pattern: indexPattern, - metaFields: [], - fieldCapsOptions: { allow_no_indices: true }, - }); }); test('should return response', async () => { @@ -117,68 +93,4 @@ describe('AbstractSearchStrategy', () => { } ); }); - - describe('toSanitizedFieldType', () => { - const mockedField = { - lang: 'lang', - conflictDescriptions: {}, - aggregatable: true, - name: 'name', - type: 'type', - esTypes: ['long', 'geo'], - } as FieldSpec; - - test('should sanitize fields ', async () => { - const fields = [mockedField] as FieldSpec[]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(` - Array [ - Object { - "label": "name", - "name": "name", - "type": "type", - }, - ] - `); - }); - - test('should filter runtime fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - runtimeField: {} as RuntimeField, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter non-aggregatable fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - aggregatable: false, - }, - ]; - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - - test('should filter nested fields', async () => { - const fields: FieldSpec[] = [ - { - ...mockedField, - subType: { - nested: { - path: 'path', - }, - }, - }, - ]; - // @ts-expect-error - indexPatterns.isNestedField.mockReturnValue(true); - - expect(toSanitizedFieldType(fields)).toMatchInlineSnapshot(`Array []`); - }); - }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 5bc008091627f..26c3a6c7c8bf7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -6,37 +6,17 @@ * Side Public License, v 1. */ -import { indexPatterns, IndexPatternsFetcher } from '../../../../../data/server'; +import { IndexPatternsService } from '../../../../../data/server'; +import { toSanitizedFieldType } from '../../../../common/fields_utils'; -import type { Framework } from '../../../plugin'; -import type { FieldSpec } from '../../../../../data/common'; -import type { SanitizedFieldType } from '../../../../common/types'; +import type { FetchedIndexPattern } from '../../../../common/types'; import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { getIndexPatternObject } from '../lib/get_index_pattern'; - -export const toSanitizedFieldType = (fields: FieldSpec[]) => { - return fields - .filter( - (field) => - // Make sure to only include mapped fields, e.g. no index pattern runtime fields - !field.runtimeField && field.aggregatable && !indexPatterns.isNestedField(field) - ) - .map( - (field) => - ({ - name: field.name, - label: field.customLabel ?? field.name, - type: field.type, - } as SanitizedFieldType) - ); -}; export abstract class AbstractSearchStrategy { - constructor(private framework: Framework) {} async search( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesVisDataRequest, @@ -66,35 +46,25 @@ export abstract class AbstractSearchStrategy { checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + fetchedIndexPattern: FetchedIndexPattern ): Promise<{ isViable: boolean; capabilities: any }> { throw new TypeError('Must override method'); } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown, options?: Partial<{ type: string; rollupIndex: string; }> ) { - const indexPatternsFetcher = new IndexPatternsFetcher( - requestContext.core.elasticsearch.client.asCurrentUser - ); - const indexPatternsService = await this.framework.getIndexPatternsService(requestContext); - const { indexPatternObject } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); - return toSanitizedFieldType( - indexPatternObject - ? indexPatternObject.getNonScriptedFields() - : await indexPatternsFetcher!.getFieldsForWildcard({ - pattern: indexPattern, - fieldCapsOptions: { allow_no_indices: true }, + fetchedIndexPattern.indexPattern + ? fetchedIndexPattern.indexPattern.getNonScriptedFields() + : await indexPatternsService.getFieldsForWildcard({ + pattern: fetchedIndexPattern.indexPatternString ?? '', metaFields: [], ...options, }) diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index b9824355374e1..d7a4e6ddedc89 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { Framework } from '../../../plugin'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, @@ -14,14 +13,13 @@ import { import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { - const framework = {} as Framework; const requestContext = {} as VisTypeTimeseriesRequestHandlerContext; let defaultSearchStrategy: DefaultSearchStrategy; let req: VisTypeTimeseriesVisDataRequest; beforeEach(() => { req = {} as VisTypeTimeseriesVisDataRequest; - defaultSearchStrategy = new DefaultSearchStrategy(framework); + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index c925d8fcbb7c3..f95bf81b5c1d3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -8,25 +8,30 @@ import { AbstractSearchStrategy } from './abstract_search_strategy'; import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; -import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequest } from '../../../types'; + +import type { IndexPatternsService } from '../../../../../data/server'; +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestHandlerContext, + VisTypeTimeseriesRequest, +} from '../../../types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { - checkForViability( + async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest ) { - return Promise.resolve({ + return { isViable: true, capabilities: new DefaultSearchCapabilities(req), - }); + }; } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities); + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index 403013cfb9e10..c798f58b0b67b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -7,8 +7,10 @@ */ import { RollupSearchStrategy } from './rollup_search_strategy'; -import { Framework } from '../../../plugin'; -import { + +import type { IndexPatternsService } from '../../../../../data/common'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; @@ -49,12 +51,11 @@ describe('Rollup Search Strategy', () => { }, }, } as unknown) as VisTypeTimeseriesRequestHandlerContext; - const framework = {} as Framework; const indexPattern = 'indexPattern'; test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(framework); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy).toBeDefined(); }); @@ -64,7 +65,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => Promise.resolve({ [rollupIndex]: { @@ -99,7 +100,7 @@ describe('Rollup Search Strategy', () => { const result = await rollupSearchStrategy.checkForViability( requestContext, {} as VisTypeTimeseriesVisDataRequest, - (null as unknown) as string + { indexPatternString: (null as unknown) as string, indexPattern: undefined } ); expect(result).toEqual({ @@ -113,7 +114,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy: RollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -140,7 +141,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(framework); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { @@ -154,9 +155,9 @@ describe('Rollup Search Strategy', () => { test('should return fields for wildcard', async () => { const fields = await rollupSearchStrategy.getFieldsForWildcard( - requestContext, - {} as VisTypeTimeseriesVisDataRequest, - indexPattern, + { indexPatternString: 'indexPattern', indexPattern: undefined }, + {} as IndexPatternsService, + (() => Promise.resolve({}) as unknown) as CachedIndexPatternFetcher, { fieldsCapabilities, rollupIndex, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 376d551624c8a..e6333ca420e0d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -6,19 +6,20 @@ * Side Public License, v 1. */ -import { getCapabilitiesForRollupIndices } from '../../../../../data/server'; -import { +import { getCapabilitiesForRollupIndices, IndexPatternsService } from '../../../../../data/server'; +import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; + +import type { FetchedIndexPattern } from '../../../../common/types'; +import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; +import type { VisTypeTimeseriesRequest, VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesVisDataRequest, } from '../../../types'; -import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); -const isIndexPatternValid = (indexPattern: string) => - indexPattern && typeof indexPattern === 'string' && !isIndexPatternContainsWildcard(indexPattern); export class RollupSearchStrategy extends AbstractSearchStrategy { async search( @@ -33,24 +34,33 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { requestContext: VisTypeTimeseriesRequestHandlerContext, indexPattern: string ) { - return requestContext.core.elasticsearch.client.asCurrentUser.rollup - .getRollupIndexCaps({ + try { + const { + body, + } = await requestContext.core.elasticsearch.client.asCurrentUser.rollup.getRollupIndexCaps({ index: indexPattern, - }) - .then((data) => data.body) - .catch(() => Promise.resolve({})); + }); + + return body; + } catch (e) { + return {}; + } } async checkForViability( requestContext: VisTypeTimeseriesRequestHandlerContext, req: VisTypeTimeseriesRequest, - indexPattern: string + { indexPatternString, indexPattern }: FetchedIndexPattern ) { let isViable = false; let capabilities = null; - if (isIndexPatternValid(indexPattern)) { - const rollupData = await this.getRollupData(requestContext, indexPattern); + if ( + indexPatternString && + !isIndexPatternContainsWildcard(indexPatternString) && + (!indexPattern || indexPattern.type === 'rollup') + ) { + const rollupData = await this.getRollupData(requestContext, indexPatternString); const rollupIndices = getRollupIndices(rollupData); isViable = rollupIndices.length === 1; @@ -70,14 +80,14 @@ export class RollupSearchStrategy extends AbstractSearchStrategy { } async getFieldsForWildcard( - requestContext: VisTypeTimeseriesRequestHandlerContext, - req: VisTypeTimeseriesRequest, - indexPattern: string, + fetchedIndexPattern: FetchedIndexPattern, + indexPatternsService: IndexPatternsService, + getCachedIndexPatternFetcher: CachedIndexPatternFetcher, capabilities?: unknown ) { - return super.getFieldsForWildcard(requestContext, req, indexPattern, capabilities, { + return super.getFieldsForWildcard(fetchedIndexPattern, indexPatternsService, capabilities, { type: 'rollup', - rollupIndex: indexPattern, + rollupIndex: fetchedIndexPattern.indexPatternString, }); } } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts index c489a8d20b071..32086fbf4f5b4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/annotations/get_request_params.ts @@ -8,7 +8,6 @@ import { AnnotationItemsSchema, PanelSchema } from 'src/plugins/vis_type_timeseries/common/types'; import { buildAnnotationRequest } from './build_request_body'; -import { getIndexPatternObject } from '../../search_strategies/lib/get_index_pattern'; import { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRequestServices, @@ -30,21 +29,20 @@ export async function getAnnotationRequestParams( esShardTimeout, esQueryConfig, capabilities, - indexPatternsService, uiSettings, + cachedIndexPatternFetcher, }: AnnotationServices ) { - const { - indexPatternObject, - indexPatternString, - } = await getIndexPatternObject(annotation.index_pattern!, { indexPatternsService }); + const { indexPattern, indexPatternString } = await cachedIndexPatternFetcher( + annotation.index_pattern + ); const request = await buildAnnotationRequest( req, panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index 9b371a8901e81..ebab984ff25aa 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -10,8 +10,8 @@ import { AUTO_INTERVAL } from '../../../common/constants'; const DEFAULT_TIME_FIELD = '@timestamp'; -export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { - const getDefaultTimeField = () => indexPatternObject?.timeFieldName ?? DEFAULT_TIME_FIELD; +export function getIntervalAndTimefield(panel, series = {}, indexPattern) { + const getDefaultTimeField = () => indexPattern?.timeFieldName ?? DEFAULT_TIME_FIELD; const timeField = (series.override_index_pattern && series.series_time_field) || diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts index f521de632b1f8..13dc1207f51de 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.ts @@ -21,6 +21,7 @@ import type { VisTypeTimeseriesRequestServices, } from '../../types'; import type { PanelSchema } from '../../../common/types'; +import { PANEL_TYPES } from '../../../common/panel_types'; export async function getSeriesData( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -28,10 +29,12 @@ export async function getSeriesData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const strategy = await services.searchStrategyRegistry.getViableStrategyForPanel( + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); + + const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panel + panelIndex ); if (!strategy) { @@ -50,14 +53,15 @@ export async function getSeriesData( try { const bodiesPromises = getActiveSeries(panel).map((series) => - getSeriesRequestParams(req, panel, series, capabilities, services) + getSeriesRequestParams(req, panel, panelIndex, series, capabilities, services) ); const searches = await Promise.all(bodiesPromises); const data = await searchStrategy.search(requestContext, req, searches); const handleResponseBodyFn = handleResponseBody(panel, req, { - requestContext, + indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, searchStrategy, capabilities, }); @@ -70,7 +74,7 @@ export async function getSeriesData( let annotations = null; - if (panel.annotations && panel.annotations.length) { + if (panel.type === PANEL_TYPES.TIMESERIES && panel.annotations && panel.annotations.length) { annotations = await getAnnotations({ req, panel, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts index a35a3246b0dd3..0cc1188086b7b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.ts @@ -16,8 +16,8 @@ import { buildRequestBody } from './table/build_request_body'; import { handleErrorResponse } from './handle_error_response'; // @ts-expect-error import { processBucket } from './table/process_bucket'; -import { getIndexPatternObject } from '../search_strategies/lib/get_index_pattern'; -import { createFieldsFetcher } from './helpers/fields_fetcher'; + +import { createFieldsFetcher } from '../search_strategies/lib/fields_fetcher'; import { extractFieldLabel } from '../../../common/calculate_label'; import type { VisTypeTimeseriesRequestHandlerContext, @@ -32,12 +32,12 @@ export async function getTableData( panel: PanelSchema, services: VisTypeTimeseriesRequestServices ) { - const panelIndexPattern = panel.index_pattern; + const panelIndex = await services.cachedIndexPatternFetcher(panel.index_pattern); const strategy = await services.searchStrategyRegistry.getViableStrategy( requestContext, req, - panelIndexPattern + panelIndex ); if (!strategy) { @@ -49,15 +49,17 @@ export async function getTableData( } const { searchStrategy, capabilities } = strategy; - const { indexPatternObject } = await getIndexPatternObject(panelIndexPattern, { + + const extractFields = createFieldsFetcher(req, { indexPatternsService: services.indexPatternsService, + cachedIndexPatternFetcher: services.cachedIndexPatternFetcher, + searchStrategy, + capabilities, }); - const extractFields = createFieldsFetcher(req, { requestContext, searchStrategy, capabilities }); - const calculatePivotLabel = async () => { - if (panel.pivot_id && indexPatternObject?.title) { - const fields = await extractFields(indexPatternObject.title); + if (panel.pivot_id && panelIndex.indexPattern?.title) { + const fields = await extractFields(panelIndex.indexPattern.title); return extractFieldLabel(fields, panel.pivot_id); } @@ -75,7 +77,7 @@ export async function getTableData( req, panel, services.esQueryConfig, - indexPatternObject, + panelIndex.indexPattern, capabilities, services.uiSettings ); @@ -83,7 +85,7 @@ export async function getTableData( const [resp] = await searchStrategy.search(requestContext, req, [ { body, - index: panelIndexPattern, + index: panelIndex.indexPatternString, }, ]); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 0d100f6310b99..48b33c1e787e9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -18,7 +18,7 @@ export function dateHistogram( panel, annotation, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index 9ff0325b60e82..dab9a24d06c0f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -19,7 +19,7 @@ export function dateHistogram( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { @@ -27,11 +27,7 @@ export function dateHistogram( const maxBarsUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval, maxBars } = getIntervalAndTimefield( - panel, - series, - indexPatternObject - ); + const { timeField, interval, maxBars } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize, intervalString } = getBucketSize( req, interval, @@ -68,7 +64,7 @@ export function dateHistogram( overwrite(doc, `aggs.${series.id}.meta`, { timeField, intervalString, - index: indexPatternObject?.title, + index: indexPattern?.title, bucketSize, seriesId: series.id, }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index d653f6acf6f3e..945c57b2341f3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -16,7 +16,7 @@ describe('dateHistogram(req, panel, series)', () => { let req; let capabilities; let config; - let indexPatternObject; + let indexPattern; let uiSettings; beforeEach(() => { @@ -39,7 +39,7 @@ describe('dateHistogram(req, panel, series)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - indexPatternObject = {}; + indexPattern = {}; capabilities = new DefaultSearchCapabilities(req); uiSettings = { get: async (key) => (key === UI_SETTINGS.HISTOGRAM_MAX_BARS ? 100 : 50), @@ -49,15 +49,9 @@ describe('dateHistogram(req, panel, series)', () => { test('calls next when finished', async () => { const next = jest.fn(); - await dateHistogram( - req, - panel, - series, - config, - indexPatternObject, - capabilities, - uiSettings - )(next)({}); + await dateHistogram(req, panel, series, config, indexPattern, capabilities, uiSettings)(next)( + {} + ); expect(next.mock.calls.length).toEqual(1); }); @@ -69,7 +63,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -110,7 +104,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -154,7 +148,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -198,7 +192,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); @@ -216,7 +210,7 @@ describe('dateHistogram(req, panel, series)', () => { panel, series, config, - indexPatternObject, + indexPattern, capabilities, uiSettings )(next)({}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js index 31ae988718a27..4639af9db83b8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -12,19 +12,19 @@ import { esQuery } from '../../../../../../data/server'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, series, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { if (series.metrics.some(filter)) { series.metrics.filter(filter).forEach((metric) => { overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js index 9e0dd4f76c13f..345488ec01d5e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.test.js @@ -13,7 +13,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => let series; let req; let esQueryConfig; - let indexPatternObject; + let indexPattern; beforeEach(() => { panel = { time_field: 'timestamp', @@ -47,18 +47,18 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => queryStringOptions: { analyze_wildcard: true }, ignoreFilterIfFieldNotInIndex: false, }; - indexPatternObject = {}; + indexPattern = {}; }); test('calls next when finished', () => { const next = jest.fn(); - ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(next.mock.calls.length).toEqual(1); }); test('returns filter ratio aggs', () => { const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { @@ -135,7 +135,7 @@ describe('ratios(req, panel, series, esQueryConfig, indexPatternObject)', () => test('returns empty object when field is not set', () => { delete series.metrics[0].field; const next = (doc) => doc; - const doc = ratios(req, panel, series, esQueryConfig, indexPatternObject)(next)({}); + const doc = ratios(req, panel, series, esQueryConfig, indexPattern)(next)({}); expect(doc).toEqual({ aggs: { test: { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 649b3cee6ea3e..86b691f6496c9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -17,14 +17,14 @@ export function metricBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 1d67df7c92eb6..ce61374c0b124 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -56,14 +56,14 @@ export function positiveRate( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); if (series.metrics.some(filter)) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js index cb12aa3513b91..d0e92c9157cb5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/query.js @@ -10,16 +10,16 @@ import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, series, esQueryConfig, indexPatternObject) { +export function query(req, panel, series, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, series, indexPattern); const { from, to } = offsetTime(req, series.offset_time); doc.size = 0; const ignoreGlobalFilter = panel.ignore_global_filter || series.ignore_global_filter; const queries = !ignoreGlobalFilter ? req.body.query : []; const filters = !ignoreGlobalFilter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -34,13 +34,13 @@ export function query(req, panel, series, esQueryConfig, indexPatternObject) { if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } if (series.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [series.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index 315ccdfc13a47..401344d48f865 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -17,13 +17,13 @@ export function siblingBuckets( panel, series, esQueryConfig, - indexPatternObject, + indexPattern, capabilities, uiSettings ) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, series, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, series, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); series.metrics diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 0ae6d113e28e4..5518065643172 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -15,20 +15,13 @@ import { calculateAggRoot } from './calculate_agg_root'; import { search, UI_SETTINGS } from '../../../../../../../plugins/data/server'; const { dateHistogramInterval } = search.aggs; -export function dateHistogram( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function dateHistogram(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField, interval } = getIntervalAndTimefield(panel, {}, indexPattern); const meta = { timeField, - index: indexPatternObject?.title, + index: indexPattern?.title, }; const getDateHistogramForLastBucketMode = () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js index 7b3ac16cd6561..abb5971908771 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js @@ -13,7 +13,7 @@ import { calculateAggRoot } from './calculate_agg_root'; const filter = (metric) => metric.type === 'filter_ratio'; -export function ratios(req, panel, esQueryConfig, indexPatternObject) { +export function ratios(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { panel.series.forEach((column) => { const aggRoot = calculateAggRoot(doc, column); @@ -22,12 +22,12 @@ export function ratios(req, panel, esQueryConfig, indexPatternObject) { overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.numerator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.numerator, [], esQueryConfig) ); overwrite( doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, - esQuery.buildEsQuery(indexPatternObject, metric.denominator, [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, metric.denominator, [], esQueryConfig) ); let numeratorPath = `${metric.id}-numerator>_count`; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 53149a31603ef..5ce508bd9b279 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function metricBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function metricBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js index 8c7a0f5e2367f..176721e7b563a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/positive_rate.js @@ -12,17 +12,10 @@ import { calculateAggRoot } from './calculate_agg_root'; import { createPositiveRate, filter } from '../series/positive_rate'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function positiveRate( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function positiveRate(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { intervalString } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js index a0118c5037d34..76df07b76e80e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/query.js @@ -10,16 +10,16 @@ import { getTimerange } from '../../helpers/get_timerange'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { esQuery } from '../../../../../../data/server'; -export function query(req, panel, esQueryConfig, indexPatternObject) { +export function query(req, panel, esQueryConfig, indexPattern) { return (next) => (doc) => { - const { timeField } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { timeField } = getIntervalAndTimefield(panel, {}, indexPattern); const { from, to } = getTimerange(req); doc.size = 0; const queries = !panel.ignore_global_filter ? req.body.query : []; const filters = !panel.ignore_global_filter ? req.body.filters : []; - doc.query = esQuery.buildEsQuery(indexPatternObject, queries, filters, esQueryConfig); + doc.query = esQuery.buildEsQuery(indexPattern, queries, filters, esQueryConfig); const timerange = { range: { @@ -33,7 +33,7 @@ export function query(req, panel, esQueryConfig, indexPatternObject) { doc.query.bool.must.push(timerange); if (panel.filter) { doc.query.bool.must.push( - esQuery.buildEsQuery(indexPatternObject, [panel.filter], [], esQueryConfig) + esQuery.buildEsQuery(indexPattern, [panel.filter], [], esQueryConfig) ); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index d205f0679a908..5539f16df41e0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -13,17 +13,10 @@ import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { calculateAggRoot } from './calculate_agg_root'; import { UI_SETTINGS } from '../../../../../../data/common'; -export function siblingBuckets( - req, - panel, - esQueryConfig, - indexPatternObject, - capabilities, - uiSettings -) { +export function siblingBuckets(req, panel, esQueryConfig, indexPattern, capabilities, uiSettings) { return (next) => async (doc) => { const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); - const { interval } = getIntervalAndTimefield(panel, {}, indexPatternObject); + const { interval } = getIntervalAndTimefield(panel, {}, indexPattern); const { bucketSize } = getBucketSize(req, interval, capabilities, barTargetUiSettings); panel.series.forEach((column) => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts index 968fe01565b04..d97af8ac748f4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/build_request_body.test.ts @@ -79,13 +79,13 @@ describe('buildRequestBody(req)', () => { allowLeadingWildcards: true, queryStringOptions: {}, }; - const indexPatternObject = {}; + const indexPattern = {}; const doc = await buildRequestBody( { body }, panel, series, config, - indexPatternObject, + indexPattern, capabilities, { get: async () => 50, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts index ae846b5b4b817..1f2735da8fb06 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/get_request_params.ts @@ -6,43 +6,46 @@ * Side Public License, v 1. */ -import { PanelSchema, SeriesItemsSchema } from '../../../../common/types'; import { buildRequestBody } from './build_request_body'; -import { getIndexPatternObject } from '../../../lib/search_strategies/lib/get_index_pattern'; -import { VisTypeTimeseriesRequestServices, VisTypeTimeseriesVisDataRequest } from '../../../types'; -import { DefaultSearchCapabilities } from '../../search_strategies'; + +import type { FetchedIndexPattern, PanelSchema, SeriesItemsSchema } from '../../../../common/types'; +import type { + VisTypeTimeseriesRequestServices, + VisTypeTimeseriesVisDataRequest, +} from '../../../types'; +import type { DefaultSearchCapabilities } from '../../search_strategies'; export async function getSeriesRequestParams( req: VisTypeTimeseriesVisDataRequest, panel: PanelSchema, + panelIndex: FetchedIndexPattern, series: SeriesItemsSchema, capabilities: DefaultSearchCapabilities, { esQueryConfig, esShardTimeout, uiSettings, - indexPatternsService, + cachedIndexPatternFetcher, }: VisTypeTimeseriesRequestServices ) { - const indexPattern = - (series.override_index_pattern && series.series_index_pattern) || panel.index_pattern; + let seriesIndex = panelIndex; - const { indexPatternObject, indexPatternString } = await getIndexPatternObject(indexPattern, { - indexPatternsService, - }); + if (series.override_index_pattern) { + seriesIndex = await cachedIndexPatternFetcher(series.series_index_pattern ?? ''); + } const request = await buildRequestBody( req, panel, series, esQueryConfig, - indexPatternObject, + seriesIndex.indexPattern, capabilities, uiSettings ); return { - index: indexPatternString, + index: seriesIndex.indexPatternString, body: { ...request, timeout: esShardTimeout > 0 ? `${esShardTimeout}ms` : undefined, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts index 22e0372c23526..49f1ec0f93de5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/series/handle_response_body.ts @@ -12,7 +12,10 @@ import { PanelSchema } from '../../../../common/types'; import { buildProcessorFunction } from '../build_processor_function'; // @ts-expect-error import { processors } from '../response_processors/series'; -import { createFieldsFetcher, FieldsFetcherServices } from './../helpers/fields_fetcher'; +import { + createFieldsFetcher, + FieldsFetcherServices, +} from '../../search_strategies/lib/fields_fetcher'; import { VisTypeTimeseriesVisDataRequest } from '../../../types'; export function handleResponseBody( diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index 71b76dddbca6a..8bc752e944709 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -37,6 +37,8 @@ import { } from './lib/search_strategies'; import { TimeseriesVisData, VisPayload } from '../common/types'; +import { registerTimeseriesUsageCollector } from './usage_collector'; + export interface LegacySetup { server: Server; } @@ -111,12 +113,16 @@ export class VisTypeTimeseriesPlugin implements Plugin { }, }; - searchStrategyRegistry.addStrategy(new DefaultSearchStrategy(framework)); - searchStrategyRegistry.addStrategy(new RollupSearchStrategy(framework)); + searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); + searchStrategyRegistry.addStrategy(new RollupSearchStrategy()); visDataRoutes(router, framework); fieldsRoutes(router, framework); + if (plugins.usageCollection) { + registerTimeseriesUsageCollector(plugins.usageCollection, globalConfig$); + } + return { getVisData: async ( requestContext: VisTypeTimeseriesRequestHandlerContext, diff --git a/src/plugins/vis_type_timeseries/server/types.ts b/src/plugins/vis_type_timeseries/server/types.ts index da32669b3855d..7b42cf61d52b3 100644 --- a/src/plugins/vis_type_timeseries/server/types.ts +++ b/src/plugins/vis_type_timeseries/server/types.ts @@ -6,14 +6,19 @@ * Side Public License, v 1. */ +import { Observable } from 'rxjs'; +import { SharedGlobalConfig } from 'kibana/server'; import type { IRouter, IUiSettingsClient, KibanaRequest } from 'src/core/server'; import type { DataRequestHandlerContext, EsQueryConfig, IndexPatternsService, } from '../../data/server'; -import { VisPayload } from '../common/types'; -import { SearchStrategyRegistry } from './lib/search_strategies'; +import type { VisPayload } from '../common/types'; +import type { SearchStrategyRegistry } from './lib/search_strategies'; +import type { CachedIndexPatternFetcher } from './lib/search_strategies/lib/cached_index_pattern_fetcher'; + +export type ConfigObservable = Observable; export type VisTypeTimeseriesRequestHandlerContext = DataRequestHandlerContext; export type VisTypeTimeseriesRouter = IRouter; @@ -29,4 +34,5 @@ export interface VisTypeTimeseriesRequestServices { uiSettings: IUiSettingsClient; indexPatternsService: IndexPatternsService; searchStrategyRegistry: SearchStrategyRegistry; + cachedIndexPatternFetcher: CachedIndexPatternFetcher; } diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.ts new file mode 100644 index 0000000000000..bb52d215c67e8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 mockStats = { somestat: 1 }; +export const mockGetStats = jest.fn().mockResolvedValue(mockStats); + +jest.doMock('./get_usage_collector', () => ({ + getStats: mockGetStats, +})); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts new file mode 100644 index 0000000000000..8ecc02072905f --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { getStats } from './get_usage_collector'; +import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { TIME_RANGE_DATA_MODES } from '../../common/timerange_data_modes'; + +const mockedSavedObjects = [ + { + _id: 'visualization:timeseries-123', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 1', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, + }, + }), + }, + }, + }, + { + _id: 'visualization:timeseries-321', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 2', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.LAST_VALUE, + }, + }), + }, + }, + }, + { + _id: 'visualization:timeseries-456', + _source: { + type: 'visualization', + visualization: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 3', + params: { + time_range_mode: undefined, + }, + }), + }, + }, + }, +]; + +const mockedSavedObjectsByValue = [ + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.LAST_VALUE, + }, + }, + }, + }), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + time_range_mode: TIME_RANGE_DATA_MODES.ENTIRE_TIME_RANGE, + }, + }, + }, + }), + }, + }, +]; + +const getMockCollectorFetchContext = (hits?: unknown[], savedObjectsByValue: unknown[] = []) => { + const fetchParamsMock = createCollectorFetchContextMock(); + + fetchParamsMock.esClient.search = jest.fn().mockResolvedValue({ body: { hits: { hits } } }); + fetchParamsMock.soClient.find = jest.fn().mockResolvedValue({ + saved_objects: savedObjectsByValue, + }); + return fetchParamsMock; +}; + +describe('Timeseries visualization usage collector', () => { + const mockIndex = 'mock_index'; + + test('Returns undefined when no results found (undefined)', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext([], []); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toBeUndefined(); + }); + + test('Returns undefined when no timeseries saved objects found', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext( + [ + { + _id: 'visualization:myvis-123', + _source: { + type: 'visualization', + visualization: { visState: '{"type": "area"}' }, + }, + }, + ], + [ + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'area', + }, + }, + }), + }, + }, + ] + ); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toBeUndefined(); + }); + + test('Summarizes visualizations response data', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext( + mockedSavedObjects, + mockedSavedObjectsByValue + ); + const result = await getStats( + mockCollectorFetchContext.esClient, + mockCollectorFetchContext.soClient, + mockIndex + ); + + expect(result).toMatchObject({ + timeseries_use_last_value_mode_total: 3, + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts new file mode 100644 index 0000000000000..c1a8715f72227 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/get_usage_collector.ts @@ -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 { ElasticsearchClient } from 'src/core/server'; +import { SavedObjectsClientContract, ISavedObjectsRepository } from 'kibana/server'; +import { TIME_RANGE_DATA_MODES } from '../../common/timerange_data_modes'; +import { findByValueEmbeddables } from '../../../dashboard/server'; + +export interface TimeseriesUsage { + timeseries_use_last_value_mode_total: number; +} + +interface VisState { + type?: string; + params?: any; +} + +export const getStats = async ( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract | ISavedObjectsRepository, + index: string +): Promise => { + const timeseriesUsage = { + timeseries_use_last_value_mode_total: 0, + }; + + const searchParams = { + size: 10000, + index, + ignoreUnavailable: true, + filterPath: ['hits.hits._id', 'hits.hits._source.visualization'], + body: { + query: { + bool: { + filter: { term: { type: 'visualization' } }, + }, + }, + }, + }; + + const { body: esResponse } = await esClient.search<{ + visualization: { visState: string }; + updated_at: string; + }>(searchParams); + + function telemetryUseLastValueMode(visState: VisState) { + if ( + visState.type === 'metrics' && + visState.params.type !== 'timeseries' && + (!visState.params.time_range_mode || + visState.params.time_range_mode === TIME_RANGE_DATA_MODES.LAST_VALUE) + ) { + timeseriesUsage.timeseries_use_last_value_mode_total++; + } + } + + if (esResponse?.hits?.hits?.length) { + for (const hit of esResponse.hits.hits) { + if (hit._source && 'visualization' in hit._source) { + const { visualization } = hit._source!; + + let visState: VisState = {}; + try { + visState = JSON.parse(visualization?.visState ?? '{}'); + } catch (e) { + // invalid visState + } + + telemetryUseLastValueMode(visState); + } + } + } + + const byValueVisualizations = await findByValueEmbeddables(soClient, 'visualization'); + + for (const item of byValueVisualizations) { + telemetryUseLastValueMode(item.savedVis as VisState); + } + + return timeseriesUsage.timeseries_use_last_value_mode_total ? timeseriesUsage : undefined; +}; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/index.ts b/src/plugins/vis_type_timeseries/server/usage_collector/index.ts new file mode 100644 index 0000000000000..7f72662e154ea --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerTimeseriesUsageCollector } from './register_timeseries_collector'; diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts new file mode 100644 index 0000000000000..2612a3882af2d --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { of } from 'rxjs'; +import { mockStats, mockGetStats } from './get_usage_collector.mock'; +import { createUsageCollectionSetupMock } from 'src/plugins/usage_collection/server/usage_collection.mock'; +import { createCollectorFetchContextMock } from 'src/plugins/usage_collection/server/mocks'; +import { registerTimeseriesUsageCollector } from './register_timeseries_collector'; +import { ConfigObservable } from '../types'; + +describe('registerTimeseriesUsageCollector', () => { + const mockIndex = 'mock_index'; + const mockConfig = of({ kibana: { index: mockIndex } }) as ConfigObservable; + + it('makes a usage collector and registers it`', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + expect(mockCollectorSet.makeUsageCollector).toBeCalledTimes(1); + expect(mockCollectorSet.registerCollector).toBeCalledTimes(1); + }); + + it('makeUsageCollector configs fit the shape', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + expect(mockCollectorSet.makeUsageCollector).toHaveBeenCalledWith({ + type: 'vis_type_timeseries', + isReady: expect.any(Function), + fetch: expect.any(Function), + schema: expect.any(Object), + }); + const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + expect(usageCollectorConfig.isReady()).toBe(true); + }); + + it('makeUsageCollector config.isReady returns true', () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + const usageCollectorConfig = mockCollectorSet.makeUsageCollector.mock.calls[0][0]; + expect(usageCollectorConfig.isReady()).toBe(true); + }); + + it('makeUsageCollector config.fetch calls getStats', async () => { + const mockCollectorSet = createUsageCollectionSetupMock(); + registerTimeseriesUsageCollector(mockCollectorSet, mockConfig); + const usageCollector = mockCollectorSet.makeUsageCollector.mock.results[0].value; + const mockedCollectorFetchContext = createCollectorFetchContextMock(); + const fetchResult = await usageCollector.fetch(mockedCollectorFetchContext); + expect(mockGetStats).toBeCalledTimes(1); + expect(mockGetStats).toBeCalledWith( + mockedCollectorFetchContext.esClient, + mockedCollectorFetchContext.soClient, + mockIndex + ); + expect(fetchResult).toBe(mockStats); + }); +}); diff --git a/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts new file mode 100644 index 0000000000000..5edeb6654020e --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/usage_collector/register_timeseries_collector.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { first } from 'rxjs/operators'; +import { getStats, TimeseriesUsage } from './get_usage_collector'; +import { ConfigObservable } from '../types'; + +export function registerTimeseriesUsageCollector( + collectorSet: UsageCollectionSetup, + config: ConfigObservable +) { + const collector = collectorSet.makeUsageCollector({ + type: 'vis_type_timeseries', + isReady: () => true, + schema: { + timeseries_use_last_value_mode_total: { + type: 'long', + _meta: { description: 'Number of TSVB visualizations using "last value" as a time range' }, + }, + }, + fetch: async ({ esClient, soClient }) => { + const { index } = (await config.pipe(first()).toPromise()).kibana; + + return await getStats(esClient, soClient, index); + }, + }); + + collectorSet.registerCollector(collector); +} diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 349e024f31c31..c2b9fcd77757a 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -12,6 +12,8 @@ import { first } from 'rxjs/operators'; import { EmbeddableStateWithType } from 'src/plugins/embeddable/common'; import { SavedObjectAttributes } from '../../../../core/public'; import { extractSearchSourceReferences } from '../../../data/public'; +import { SavedObjectReference } from '../../../../core/public'; + import { EmbeddableFactoryDefinition, EmbeddableOutput, @@ -38,6 +40,12 @@ import { } from '../services'; import { showNewVisModal } from '../wizard'; import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; +import { + extractControlsReferences, + extractTimeSeriesReferences, + injectTimeSeriesReferences, + injectControlsReferences, +} from '../saved_visualizations/saved_visualization_references'; import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object'; import { StartServicesGetter } from '../../../kibana_utils/public'; import { VisualizationsStartDeps } from '../plugin'; @@ -239,6 +247,19 @@ export class VisualizeEmbeddableFactory ); } + public inject(_state: EmbeddableStateWithType, references: SavedObjectReference[]) { + const state = (_state as unknown) as VisualizeInput; + + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); + } + + return _state; + } + public extract(_state: EmbeddableStateWithType) { const state = (_state as unknown) as VisualizeInput; const references = []; @@ -259,19 +280,11 @@ export class VisualizeEmbeddableFactory }); } - if (state.savedVis?.params.controls) { - const controls = state.savedVis.params.controls; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - references.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - }); + const { type, params } = state.savedVis ?? {}; + + if (type && params) { + extractControlsReferences(type, params, references, `control_${state.id}`); + extractTimeSeriesReferences(type, params, references, `metrics_${state.id}`); } return { state: _state, references }; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts new file mode 100644 index 0000000000000..d116fd2e2e9a7 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +const isControlsVis = (visType: string) => visType === 'input_control_vis'; + +export const extractControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'control' +) => { + if (isControlsVis(visType)) { + (visParams?.controls ?? []).forEach((control: Record, i: number) => { + if (!control.indexPattern) { + return; + } + control.indexPatternRefName = `${prefix}_${i}_index_pattern`; + references.push({ + name: control.indexPatternRefName, + type: 'index-pattern', + id: control.indexPattern, + }); + delete control.indexPattern; + }); + } +}; + +export const injectControlsReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isControlsVis(visType)) { + (visParams.controls ?? []).forEach((control: Record) => { + if (!control.indexPatternRefName) { + return; + } + const reference = references.find((ref) => ref.name === control.indexPatternRefName); + if (!reference) { + throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); + } + control.indexPattern = reference.id; + delete control.indexPatternRefName; + }); + } +}; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts new file mode 100644 index 0000000000000..0acda1c0a0f80 --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { extractControlsReferences, injectControlsReferences } from './controls_references'; +export { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; + +export { extractReferences, injectReferences } from './saved_visualization_references'; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts similarity index 69% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts index f81054febcc44..867febd2544b0 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.test.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts @@ -7,8 +7,8 @@ */ import { extractReferences, injectReferences } from './saved_visualization_references'; -import { VisSavedObject } from '../types'; -import { SavedVisState } from '../../common'; +import { VisSavedObject } from '../../types'; +import { SavedVisState } from '../../../common'; describe('extractReferences', () => { test('extracts nothing if savedSearchId is empty', () => { @@ -21,13 +21,13 @@ describe('extractReferences', () => { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - }, - "references": Array [], -} -`); + Object { + "attributes": Object { + "foo": true, + }, + "references": Array [], + } + `); }); test('extracts references from savedSearchId', () => { @@ -41,20 +41,20 @@ Object { }; const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "savedSearchRefName": "search_0", - }, - "references": Array [ - Object { - "id": "123", - "name": "search_0", - "type": "search", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "savedSearchRefName": "search_0", + }, + "references": Array [ + Object { + "id": "123", + "name": "search_0", + "type": "search", + }, + ], + } + `); }); test('extracts references from controls', () => { @@ -63,6 +63,7 @@ Object { attributes: { foo: true, visState: JSON.stringify({ + type: 'input_control_vis', params: { controls: [ { @@ -81,20 +82,20 @@ Object { const updatedDoc = extractReferences(doc); expect(updatedDoc).toMatchInlineSnapshot(` -Object { - "attributes": Object { - "foo": true, - "visState": "{\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", - }, - "references": Array [ - Object { - "id": "pattern*", - "name": "control_0_index_pattern", - "type": "index-pattern", - }, - ], -} -`); + Object { + "attributes": Object { + "foo": true, + "visState": "{\\"type\\":\\"input_control_vis\\",\\"params\\":{\\"controls\\":[{\\"bar\\":true,\\"indexPatternRefName\\":\\"control_0_index_pattern\\"},{\\"bar\\":false}]}}", + }, + "references": Array [ + Object { + "id": "pattern*", + "name": "control_0_index_pattern", + "type": "index-pattern", + }, + ], + } + `); }); }); @@ -106,11 +107,11 @@ describe('injectReferences', () => { } as VisSavedObject; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "title": "test", -} -`); + Object { + "id": "1", + "title": "test", + } + `); }); test('injects references into context', () => { @@ -119,6 +120,7 @@ Object { title: 'test', savedSearchRefName: 'search_0', visState: ({ + type: 'input_control_vis', params: { controls: [ { @@ -146,25 +148,26 @@ Object { ]; injectReferences(context, references); expect(context).toMatchInlineSnapshot(` -Object { - "id": "1", - "savedSearchId": "123", - "title": "test", - "visState": Object { - "params": Object { - "controls": Array [ - Object { - "foo": true, - "indexPattern": "pattern*", - }, - Object { - "foo": false, + Object { + "id": "1", + "savedSearchId": "123", + "title": "test", + "visState": Object { + "params": Object { + "controls": Array [ + Object { + "foo": true, + "indexPattern": "pattern*", + }, + Object { + "foo": false, + }, + ], + }, + "type": "input_control_vis", }, - ], - }, - }, -} -`); + } + `); }); test(`fails when it can't find the saved search reference in the array`, () => { @@ -183,6 +186,7 @@ Object { id: '1', title: 'test', visState: ({ + type: 'input_control_vis', params: { controls: [ { diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts similarity index 67% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts rename to src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts index 27b5a4542036b..6a4f9812db971 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts @@ -10,13 +10,16 @@ import { SavedObjectAttribute, SavedObjectAttributes, SavedObjectReference, -} from '../../../../core/public'; -import { VisSavedObject } from '../types'; +} from '../../../../../core/public'; +import { SavedVisState, VisSavedObject } from '../../types'; import { extractSearchSourceReferences, injectSearchSourceReferences, SearchSourceFields, -} from '../../../data/public'; +} from '../../../../data/public'; + +import { extractTimeSeriesReferences, injectTimeSeriesReferences } from './timeseries_references'; +import { extractControlsReferences, injectControlsReferences } from './controls_references'; export function extractReferences({ attributes, @@ -49,20 +52,13 @@ export function extractReferences({ // Extract index patterns from controls if (updatedAttributes.visState) { - const visState = JSON.parse(String(updatedAttributes.visState)); - const controls = (visState.params && visState.params.controls) || []; - controls.forEach((control: Record, i: number) => { - if (!control.indexPattern) { - return; - } - control.indexPatternRefName = `control_${i}_index_pattern`; - updatedReferences.push({ - name: control.indexPatternRefName, - type: 'index-pattern', - id: control.indexPattern, - }); - delete control.indexPattern; - }); + const visState = JSON.parse(String(updatedAttributes.visState)) as SavedVisState; + + if (visState.type && visState.params) { + extractControlsReferences(visState.type, visState.params, updatedReferences); + extractTimeSeriesReferences(visState.type, visState.params, updatedReferences); + } + updatedAttributes.visState = JSON.stringify(visState); } @@ -89,18 +85,11 @@ export function injectReferences(savedObject: VisSavedObject, references: SavedO savedObject.savedSearchId = savedSearchReference.id; delete savedObject.savedSearchRefName; } - if (savedObject.visState) { - const controls = (savedObject.visState.params && savedObject.visState.params.controls) || []; - controls.forEach((control: Record) => { - if (!control.indexPatternRefName) { - return; - } - const reference = references.find((ref) => ref.name === control.indexPatternRefName); - if (!reference) { - throw new Error(`Could not find index pattern reference "${control.indexPatternRefName}"`); - } - control.indexPattern = reference.id; - delete control.indexPatternRefName; - }); + + const { type, params } = savedObject.visState ?? {}; + + if (type && params) { + injectControlsReferences(type, params, references); + injectTimeSeriesReferences(type, params, references); } } diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts new file mode 100644 index 0000000000000..57706ee824e8d --- /dev/null +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { SavedObjectReference } from '../../../../../core/types'; +import { VisParams } from '../../../common'; + +/** @internal **/ +const REF_NAME_POSTFIX = '_ref_name'; + +/** @internal **/ +const INDEX_PATTERN_REF_TYPE = 'index_pattern'; + +/** @internal **/ +type Action = (object: Record, key: string) => void; + +const isMetricsVis = (visType: string) => visType === 'metrics'; + +const doForExtractedIndices = (action: Action, visParams: VisParams) => { + action(visParams, 'index_pattern'); + + visParams.series.forEach((series: any) => { + if (series.override_index_pattern) { + action(series, 'series_index_pattern'); + } + }); + + if (visParams.annotations) { + visParams.annotations.forEach((annotation: any) => { + action(annotation, 'index_pattern'); + }); + } +}; + +export const extractTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] = [], + prefix: string = 'metrics' +) => { + let i = 0; + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + if (object[key] && object[key].id) { + const name = `${prefix}_${i++}_index_pattern`; + + object[key + REF_NAME_POSTFIX] = name; + references.push({ + name, + type: INDEX_PATTERN_REF_TYPE, + id: object[key].id, + }); + delete object[key]; + } + }, visParams); + } +}; + +export const injectTimeSeriesReferences = ( + visType: string, + visParams: VisParams, + references: SavedObjectReference[] +) => { + if (isMetricsVis(visType)) { + doForExtractedIndices((object, key) => { + const refKey = key + REF_NAME_POSTFIX; + + if (object[refKey]) { + const refValue = references.find((ref) => ref.name === object[refKey]); + + if (refValue) { + object[key] = { id: refValue.id }; + } + + delete object[refKey]; + } + }, visParams); + } +}; diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts index afb59266d0dbf..ced33318413c5 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts @@ -790,6 +790,35 @@ const removeTSVBSearchSource: SavedObjectMigrationFn = (doc) => { return doc; }; +const addSupportOfDualIndexSelectionModeInTSVB: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + if (visState && visState.type === 'metrics') { + const { params } = visState; + + if (typeof params?.index_pattern === 'string') { + params.use_kibana_indexes = false; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(visState), + }, + }; + } + } + return doc; +}; + // [Data table visualization] Enable toolbar by default const enableDataTableVisToolbar: SavedObjectMigrationFn = (doc) => { let visState; @@ -929,4 +958,5 @@ export const visualizationSavedObjectTypeMigrations = { '7.10.0': flow(migrateFilterRatioQuery, removeTSVBSearchSource), '7.11.0': flow(enableDataTableVisToolbar), '7.12.0': flow(migrateVislibAreaLineBarTypes, migrateSchema), + '7.13.0': flow(addSupportOfDualIndexSelectionModeInTSVB), }; diff --git a/src/setup_node_env/index.js b/src/setup_node_env/index.js index 08664344db393..9ce60766997cc 100644 --- a/src/setup_node_env/index.js +++ b/src/setup_node_env/index.js @@ -7,4 +7,5 @@ */ require('./no_transpilation'); +// eslint-disable-next-line import/no-extraneous-dependencies require('@kbn/optimizer').registerNodeAutoTranspilation(); diff --git a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js b/test/api_integration/apis/console/index.ts similarity index 50% rename from src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js rename to test/api_integration/apis/console/index.ts index af8404eb6da92..ad4f8256f97ad 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.js +++ b/test/api_integration/apis/console/index.ts @@ -6,12 +6,10 @@ * Side Public License, v 1. */ -import React, { useContext } from 'react'; -import { CoreStartContext } from '../contexts/query_input_bar_context'; -import { QueryStringInput } from '../../../../../plugins/data/public'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export function QueryBarWrapper(props) { - const coreStartContext = useContext(CoreStartContext); - - return ; +export default function ({ loadTestFile }: FtrProviderContext) { + describe('core', () => { + loadTestFile(require.resolve('./proxy_route')); + }); } diff --git a/test/api_integration/apis/console/proxy_route.ts b/test/api_integration/apis/console/proxy_route.ts new file mode 100644 index 0000000000000..d8a5f57a41a6e --- /dev/null +++ b/test/api_integration/apis/console/proxy_route.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('POST /api/console/proxy', () => { + describe('system indices behavior', () => { + it('returns warning header when making requests to .kibana index', async () => { + return await supertest + .post('/api/console/proxy?method=GET&path=/.kibana/_settings') + .set('kbn-xsrf', 'true') + .then((response) => { + expect(response.header).to.have.property('warning'); + const { warning } = response.header as { warning: string }; + expect(warning.startsWith('299')).to.be(true); + expect(warning.includes('system indices')).to.be(true); + }); + }); + + it('does not forward x-elastic-product-origin', async () => { + // If we pass the header and we still get the warning back, we assume that the header was not forwarded. + return await supertest + .post('/api/console/proxy?method=GET&path=/.kibana/_settings') + .set('kbn-xsrf', 'true') + .set('x-elastic-product-origin', 'kibana') + .then((response) => { + expect(response.header).to.have.property('warning'); + const { warning } = response.header as { warning: string }; + expect(warning.startsWith('299')).to.be(true); + expect(warning.includes('system indices')).to.be(true); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index 33495ad2c604b..0d87569cb8b97 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', () => { + loadTestFile(require.resolve('./console')); loadTestFile(require.resolve('./core')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 2a6096f8d1a78..72deb74459ab9 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -22,7 +22,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const browser = getService('browser'); - describe('discover histogram', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/94532 + describe.skip('discover histogram', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.load('long_window_logstash'); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index ba500904d75c7..6b0080c3856fd 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -43,6 +43,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('metric'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('metric'); }); it('should not have inspector enabled', async () => { @@ -81,12 +84,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.checkGaugeTabIsPresent(); }); + it('should "Entire time range" selected as timerange mode for new visualization', async () => { + await PageObjects.visualBuilder.clickPanelOptions('gauge'); + await PageObjects.visualBuilder.checkSelectedDataTimerangeMode('Entire time range'); + await PageObjects.visualBuilder.clickDataTab('gauge'); + }); + it('should verify gauge label and count display', async () => { await PageObjects.visChart.waitForVisualizationRenderingStabilized(); const labelString = await PageObjects.visualBuilder.getGaugeLabel(); expect(labelString).to.be('Count'); const gaugeCount = await PageObjects.visualBuilder.getGaugeCount(); - expect(gaugeCount).to.be('156'); + expect(gaugeCount).to.be('13,830'); }); }); @@ -95,6 +104,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickTopN(); await PageObjects.visualBuilder.checkTopNTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('topN'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('topN'); }); it('should verify topN label and count display', async () => { @@ -107,33 +119,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('switch index patterns', () => { + before(async () => { + await esArchiver.loadIfNeeded('index_pattern_without_timefield'); + }); + beforeEach(async () => { - log.debug('Load kibana_sample_data_flights data'); - await esArchiver.loadIfNeeded('kibana_sample_data_flights'); await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); await PageObjects.visualBuilder.checkMetricTabIsPresent(); + await PageObjects.visualBuilder.clickPanelOptions('metric'); + await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); + await PageObjects.visualBuilder.clickDataTab('metric'); + await PageObjects.timePicker.setAbsoluteRange( + 'Sep 22, 2019 @ 00:00:00.000', + 'Sep 23, 2019 @ 00:00:00.000' + ); }); + after(async () => { await security.testUser.restoreDefaults(); - await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('index_pattern_without_timefield'); }); - it('should be able to switch between index patterns', async () => { - const value = await PageObjects.visualBuilder.getMetricValue(); - expect(value).to.eql('156'); + const switchIndexTest = async (useKibanaIndexes: boolean) => { await PageObjects.visualBuilder.clickPanelOptions('metric'); - const fromTime = 'Oct 22, 2018 @ 00:00:00.000'; - const toTime = 'Oct 28, 2018 @ 23:59:59.999'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.visualBuilder.setIndexPatternValue('', false); + + const value = await PageObjects.visualBuilder.getMetricValue(); + expect(value).to.eql('0'); + // Sometimes popovers take some time to appear in Firefox (#71979) await retry.tryForTime(20000, async () => { - await PageObjects.visualBuilder.setIndexPatternValue('kibana_sample_data_flights'); + await PageObjects.visualBuilder.setIndexPatternValue('with-timefield', useKibanaIndexes); await PageObjects.visualBuilder.waitForIndexPatternTimeFieldOptionsLoaded(); await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); }); const newValue = await PageObjects.visualBuilder.getMetricValue(); - expect(newValue).to.eql('18'); + expect(newValue).to.eql('1'); + }; + + it('should be able to switch using text mode selection', async () => { + await switchIndexTest(false); + }); + + it('should be able to switch combo box mode selection', async () => { + await switchIndexTest(true); }); }); diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index caf9cab8b703a..b61fbf967a9bd 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -37,6 +37,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'Sep 22, 2015 @ 06:00:00.000', 'Sep 22, 2015 @ 11:00:00.000' ); + await visualBuilder.markdownSwitchSubTab('options'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.markdownSwitchSubTab('markdown'); }); it('should render subtabs and table variables markdown components', async () => { diff --git a/test/functional/apps/visualize/_tsvb_table.ts b/test/functional/apps/visualize/_tsvb_table.ts index dfa232b6e527d..36c0e26430ff5 100644 --- a/test/functional/apps/visualize/_tsvb_table.ts +++ b/test/functional/apps/visualize/_tsvb_table.ts @@ -24,6 +24,9 @@ export default function ({ getPageObjects }: FtrProviderContext) { await visualBuilder.clickTable(); await visualBuilder.checkTableTabIsPresent(); + await visualBuilder.clickPanelOptions('table'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.clickDataTab('table'); await visualBuilder.selectGroupByField('machine.os.raw'); await visualBuilder.setColumnLabelValue('OS'); await visChart.waitForVisualizationRenderingStabilized(); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index d7bb84394ae3c..fbb2b101eb3af 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -431,10 +431,39 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro await PageObjects.header.waitUntilLoadingHasFinished(); } - public async setIndexPatternValue(value: string) { - const el = await testSubjects.find('metricsIndexPatternInput'); - await el.clearValue(); - await el.type(value, { charByChar: true }); + public async clickDataTab(tabName: string) { + await testSubjects.click(`${tabName}EditorDataBtn`); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + public async switchIndexPatternSelectionMode(useKibanaIndices: boolean) { + await testSubjects.click('switchIndexPatternSelectionModePopover'); + await testSubjects.setEuiSwitch( + 'switchIndexPatternSelectionMode', + useKibanaIndices ? 'check' : 'uncheck' + ); + } + + public async setIndexPatternValue(value: string, useKibanaIndices?: boolean) { + const metricsIndexPatternInput = 'metricsIndexPatternInput'; + + if (useKibanaIndices !== undefined) { + await this.switchIndexPatternSelectionMode(useKibanaIndices); + } + + if (useKibanaIndices === false) { + const el = await testSubjects.find(metricsIndexPatternInput); + await el.clearValue(); + if (value) { + await el.type(value, { charByChar: true }); + } + } else { + await comboBox.clearInputField(metricsIndexPatternInput); + if (value) { + await comboBox.setCustom(metricsIndexPatternInput, value); + } + } + await PageObjects.header.waitUntilLoadingHasFinished(); } @@ -614,6 +643,16 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro ); return await comboBox.isOptionSelected(groupBy, value); } + + public async setMetricsDataTimerangeMode(value: string) { + const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); + return await comboBox.setElement(dataTimeRangeMode, value); + } + + public async checkSelectedDataTimerangeMode(value: string) { + const dataTimeRangeMode = await testSubjects.find('dataTimeRangeMode'); + return await comboBox.isOptionSelected(dataTimeRangeMode, value); + } } return new VisualBuilderPage(); diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts index f8a91e3a0a67a..c338bbc998c49 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.ts @@ -23,6 +23,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { __servicenow: { type: 'long' }, __jira: { type: 'long' }, __resilient: { type: 'long' }, + __teams: { type: 'long' }, }; export function createActionsUsageCollector( diff --git a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts index 884120d3d03df..59aeb4854d9f0 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_usage_collector.ts @@ -16,6 +16,7 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { // Known alerts (searching the use of the alerts API `registerType`: // Built-in '__index-threshold': { type: 'long' }, + '__es-query': { type: 'long' }, // APM apm__error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention apm__transaction_error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention @@ -41,6 +42,10 @@ const byTypeSchema: MakeSchemaFrom['count_by_type'] = { xpack__uptime__alerts__monitorStatus: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__tls: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention xpack__uptime__alerts__durationAnomaly: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention + // Maps + '__geo-containment': { type: 'long' }, + // ML + xpack_ml_anomaly_detection_alert: { type: 'long' }, }; export function createAlertsUsageCollector( diff --git a/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts new file mode 100644 index 0000000000000..fb7ef6d36ce25 --- /dev/null +++ b/x-pack/plugins/apm/common/apm_api/parse_endpoint.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +type Method = 'get' | 'post' | 'put' | 'delete'; + +export function parseEndpoint( + endpoint: string, + pathParams: Record = {} +) { + const [method, rawPathname] = endpoint.split(' '); + + // replace template variables with path params + const pathname = Object.keys(pathParams).reduce((acc, paramName) => { + return acc.replace(`{${paramName}}`, pathParams[paramName]); + }, rawPathname); + + return { method: parseMethod(method), pathname }; +} + +export function parseMethod(method: string) { + const res = method.trim().toLowerCase() as Method; + + if (!['get', 'post', 'put', 'delete'].includes(res)) { + throw new Error('Endpoint was not prefixed with a valid HTTP method'); + } + + return res; +} diff --git a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts index 3316c74d52e38..4212e0430ff5f 100644 --- a/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts +++ b/x-pack/plugins/apm/common/runtime_types/strict_keys_rt/index.test.ts @@ -45,10 +45,10 @@ describe('strictKeysRt', () => { { type: t.intersection([ t.type({ query: t.type({ bar: t.string }) }), - t.partial({ query: t.partial({ _debug: t.boolean }) }), + t.partial({ query: t.partial({ _inspect: t.boolean }) }), ]), - passes: [{ query: { bar: '', _debug: true } }], - fails: [{ query: { _debug: true } }], + passes: [{ query: { bar: '', _inspect: true } }], + fails: [{ query: { _inspect: true } }], }, ]; @@ -91,12 +91,12 @@ describe('strictKeysRt', () => { } as Record); const typeB = t.partial({ - query: t.partial({ _debug: jsonRt.pipe(t.boolean) }), + query: t.partial({ _inspect: jsonRt.pipe(t.boolean) }), }); const value = { query: { - _debug: 'true', + _inspect: 'true', filterNames: JSON.stringify(['host', 'agentName']), }, }; diff --git a/x-pack/plugins/apm/public/application/application.test.tsx b/x-pack/plugins/apm/public/application/application.test.tsx index b785fcc7dab08..7df6ca343426c 100644 --- a/x-pack/plugins/apm/public/application/application.test.tsx +++ b/x-pack/plugins/apm/public/application/application.test.tsx @@ -8,7 +8,7 @@ import { act } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { Observable } from 'rxjs'; -import { AppMountParameters, CoreStart, HttpSetup } from 'src/core/public'; +import { AppMountParameters, CoreStart } from 'src/core/public'; import { mockApmPluginContextValue } from '../context/apm_plugin/mock_apm_plugin_context'; import { ApmPluginSetupDeps, ApmPluginStartDeps } from '../plugin'; import { createCallApmApi } from '../services/rest/createCallApmApi'; @@ -72,7 +72,7 @@ describe('renderApp', () => { embeddable, }; jest.spyOn(window, 'scrollTo').mockReturnValueOnce(undefined); - createCallApmApi((core.http as unknown) as HttpSetup); + createCallApmApi((core as unknown) as CoreStart); jest .spyOn(window.console, 'warn') diff --git a/x-pack/plugins/apm/public/application/csmApp.tsx b/x-pack/plugins/apm/public/application/csmApp.tsx index 8ea4593bb89a7..787b15d0a5675 100644 --- a/x-pack/plugins/apm/public/application/csmApp.tsx +++ b/x-pack/plugins/apm/public/application/csmApp.tsx @@ -118,7 +118,7 @@ export const renderApp = ( ) => { const { element } = appMountParameters; - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx index 64600dd500bd5..bc14bc1531686 100644 --- a/x-pack/plugins/apm/public/application/index.tsx +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -120,7 +120,7 @@ export const renderApp = ( // render APM feedback link in global help menu setHelpExtension(core); setReadonlyBadge(core); - createCallApmApi(core.http); + createCallApmApi(core); // Automatically creates static index pattern and stores as saved object createStaticIndexPattern().catch((e) => { diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index 29f74b26d310c..fdfed6eb0d685 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -107,7 +107,11 @@ export function ErrorCountAlertTrigger(props: Props) { ]; const chartPreview = ( - + ); return ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index 11aab788ec7f4..b4c78b54f329b 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -13,7 +13,6 @@ import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../typings/timeseries'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; @@ -116,9 +115,9 @@ export function TransactionDurationAlertTrigger(props: Props) { ] ); - const maxY = getMaxY([ - { data: data ?? [] } as TimeSeries<{ x: number; y: number | null }>, - ]); + const latencyChartPreview = data?.latencyChartPreview ?? []; + + const maxY = getMaxY([{ data: latencyChartPreview }]); const formatter = getDurationFormatter(maxY); const yTickFormat = getResponseTimeTickFormatter(formatter); @@ -127,7 +126,7 @@ export function TransactionDurationAlertTrigger(props: Props) { const chartPreview = ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx index de30af4a4707f..c6f9c4efd98b6 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -132,7 +132,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { const chartPreview = ( asPercent(d, 1)} threshold={thresholdAsPercent} /> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx index 6c94b895f6924..db5932a96fb12 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/BreakdownSeries.tsx @@ -35,7 +35,7 @@ export function BreakdownSeries({ ? EUI_CHARTS_THEME_DARK : EUI_CHARTS_THEME_LIGHT; - const { data, status } = useBreakdowns({ + const { breakdowns, status } = useBreakdowns({ field, value, percentileRange, @@ -49,7 +49,7 @@ export function BreakdownSeries({ // so don't user that here return ( <> - {data?.map(({ data: seriesData, name }, sortIndex) => ( + {breakdowns.map(({ data: seriesData, name }, sortIndex) => (
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts index 5af7f0682db19..e21aaa08c432d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/use_breakdowns.ts @@ -17,12 +17,10 @@ interface Props { export const useBreakdowns = ({ percentileRange, field, value }: Props) => { const { urlParams, uiFilters } = useUrlParams(); - const { start, end, searchTerm } = urlParams; - const { min: minP, max: maxP } = percentileRange ?? {}; - return useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (start && end && field && value) { return callApmApi({ @@ -47,4 +45,6 @@ export const useBreakdowns = ({ percentileRange, field, value }: Props) => { }, [end, start, uiFilters, field, value, minP, maxP, searchTerm] ); + + return { breakdowns: data?.pageLoadDistBreakdown ?? [], status }; }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index e3e2a979c48d3..d04bcb79a53e1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -38,6 +38,7 @@ export function MainFilters() { [start, end] ); + const rumServiceNames = data?.rumServices ?? []; const { isSmall } = useBreakPoints(); // on mobile we want it to take full width @@ -48,7 +49,7 @@ export function MainFilters() { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts index c40f6ba2b8850..8ae4c9dc0e01d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts @@ -68,7 +68,7 @@ export function useLocalUIFilters({ }); }; - const { data = getInitialData(filterNames), status } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (shouldFetch && urlParams.start && urlParams.end) { return callApmApi({ @@ -96,7 +96,8 @@ export function useLocalUIFilters({ ] ); - const filters = data.map((filter) => ({ + const localUiFilters = data?.localUiFilters ?? getInitialData(filterNames); + const filters = localUiFilters.map((filter) => ({ ...filter, value: values[filter.name] || [], })); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts index 5b448871804eb..f932cec3cacb6 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/use_call_api.ts @@ -11,9 +11,9 @@ import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plug import { FetchOptions } from '../../../../../common/fetch_options'; export function useCallApi() { - const { http } = useApmPluginContext().core; + const { core } = useApmPluginContext(); return useMemo(() => { - return (options: FetchOptions) => callApi(http, options); - }, [http]); + return (options: FetchOptions) => callApi(core, options); + }, [core]); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index d754710dc84fa..ac1846155569a 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -6,7 +6,7 @@ */ import cytoscape from 'cytoscape'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import React, { ComponentType } from 'react'; import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; @@ -21,19 +21,21 @@ export default { component: Popover, decorators: [ (Story: ComponentType) => { - const httpMock = ({ - get: async () => ({ - avgCpuUsage: 0.32809666568309237, - avgErrorRate: 0.556068173242986, - avgMemoryUsage: 0.5504868173242986, - transactionStats: { - avgRequestsPerMinute: 164.47222031860858, - avgTransactionDuration: 61634.38905590272, - }, - }), - } as unknown) as HttpSetup; + const coreMock = ({ + http: { + get: async () => ({ + avgCpuUsage: 0.32809666568309237, + avgErrorRate: 0.556068173242986, + avgMemoryUsage: 0.5504868173242986, + transactionStats: { + avgRequestsPerMinute: 164.47222031860858, + avgTransactionDuration: 61634.38905590272, + }, + }), + }, + } as unknown) as CoreStart; - createCallApmApi(httpMock); + createCallApmApi(coreMock); return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx index e762f517ce1b5..71355a84d28d4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -33,7 +33,7 @@ interface Props { } export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + const { data: serviceNamesData, status: serviceNamesStatus } = useFetcher( (callApmApi) => { return callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration/services', @@ -43,8 +43,9 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { [], { preservePreviousData: false } ); + const serviceNames = serviceNamesData?.serviceNames ?? []; - const { data: environments = [], status: environmentStatus } = useFetcher( + const { data: environmentsData, status: environmentsStatus } = useFetcher( (callApmApi) => { if (newConfig.service.name) { return callApmApi({ @@ -59,6 +60,8 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { { preservePreviousData: false } ); + const environments = environmentsData?.environments ?? []; + const { status: agentNameStatus } = useFetcher( async (callApmApi) => { const serviceName = newConfig.service.name; @@ -153,11 +156,11 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { 'xpack.apm.agentConfig.servicePage.environment.fieldLabel', { defaultMessage: 'Service environment' } )} - isLoading={environmentStatus === FETCH_STATUS.LOADING} + isLoading={environmentsStatus === FETCH_STATUS.LOADING} options={environmentOptions} value={newConfig.service.environment} disabled={ - !newConfig.service.name || environmentStatus === FETCH_STATUS.LOADING + !newConfig.service.name || environmentsStatus === FETCH_STATUS.LOADING } onChange={(e) => { e.preventDefault(); diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 4d2754a677bf7..cd5fa5db89a31 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -7,7 +7,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; import { AgentConfiguration } from '../../../../../../common/agent_configuration/configuration_types'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; @@ -23,10 +23,10 @@ storiesOf( module ) .addDecorator((storyFn) => { - const httpMock = {}; + const coreMock = ({} as unknown) as CoreStart; // mock - createCallApmApi((httpMock as unknown) as HttpSetup); + createCallApmApi(coreMock); const contextMock = { core: { diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 081a3dbc907c5..3e3bc892e6518 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -16,7 +16,7 @@ import { } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../context/apm_plugin/use_apm_plugin_context'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { config: Config; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx index bef0dfc22280c..c098be41968dd 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -32,15 +32,19 @@ import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; import { ConfirmDeleteModal } from './ConfirmDeleteModal'; -type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>[0]; +type Config = APIReturnType<'GET /api/apm/settings/agent-configuration'>['configurations'][0]; interface Props { status: FETCH_STATUS; - data: Config[]; + configurations: Config[]; refetch: () => void; } -export function AgentConfigurationList({ status, data, refetch }: Props) { +export function AgentConfigurationList({ + status, + configurations, + refetch, +}: Props) { const { core } = useApmPluginContext(); const canSave = core.application.capabilities.apm.save; const { basePath } = core.http; @@ -113,7 +117,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { return failurePrompt; } - if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + if (status === FETCH_STATUS.SUCCESS && isEmpty(configurations)) { return emptyStatePrompt; } @@ -231,7 +235,7 @@ export function AgentConfigurationList({ status, data, refetch }: Props) { } columns={columns} - items={data} + items={configurations} initialSortField="service.name" initialSortDirection="asc" initialPageSize={20} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 8aa0c35f36717..3225951fd6c70 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -25,8 +25,10 @@ import { useFetcher } from '../../../../hooks/use_fetcher'; import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; import { AgentConfigurationList } from './List'; +const INITIAL_DATA = { configurations: [] }; + export function AgentConfigurations() { - const { refetch, data = [], status } = useFetcher( + const { refetch, data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: 'GET /api/apm/settings/agent-configuration' }), [], @@ -36,7 +38,7 @@ export function AgentConfigurations() { useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); - const hasConfigurations = !isEmpty(data); + const hasConfigurations = !isEmpty(data.configurations); return ( <> @@ -72,7 +74,11 @@ export function AgentConfigurations() { - + ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx index 9722c99990e3f..9d2b4bba22afb 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx @@ -24,7 +24,10 @@ import React, { useEffect, useState } from 'react'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { clearCache } from '../../../../services/rest/callApi'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; +import { + APIReturnType, + callApmApi, +} from '../../../../services/rest/createCallApmApi'; const APM_INDEX_LABELS = [ { @@ -84,8 +87,10 @@ async function saveApmIndices({ clearCache(); } +type ApiResponse = APIReturnType<`GET /api/apm/settings/apm-index-settings`>; + // avoid infinite loop by initializing the state outside the component -const INITIAL_STATE = [] as []; +const INITIAL_STATE: ApiResponse = { apmIndexSettings: [] }; export function ApmIndices() { const { core } = useApmPluginContext(); @@ -108,7 +113,7 @@ export function ApmIndices() { useEffect(() => { setApmIndices( - data.reduce( + data.apmIndexSettings.reduce( (acc, { configurationName, savedValue }) => ({ ...acc, [configurationName]: savedValue, @@ -190,7 +195,7 @@ export function ApmIndices() { {APM_INDEX_LABELS.map(({ configurationName, label }) => { - const matchedConfiguration = data.find( + const matchedConfiguration = data.apmIndexSettings.find( ({ configurationName: configName }) => configName === configurationName ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 0dbc8f6235342..77835afef863a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -24,20 +24,12 @@ import { } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CreateEditCustomLinkFlyout/saveCustomLink'; -const data = [ - { - id: '1', - label: 'label 1', - url: 'url 1', - 'service.name': 'opbeans-java', - }, - { - id: '2', - label: 'label 2', - url: 'url 2', - 'transaction.type': 'request', - }, -]; +const data = { + customLinks: [ + { id: '1', label: 'label 1', url: 'url 1', 'service.name': 'opbeans-java' }, + { id: '2', label: 'label 2', url: 'url 2', 'transaction.type': 'request' }, + ], +}; function getMockAPMContext({ canSave }: { canSave: boolean }) { return ({ @@ -69,7 +61,7 @@ describe('CustomLink', () => { describe('empty prompt', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -290,7 +282,7 @@ describe('CustomLink', () => { describe('invalid license', () => { beforeAll(() => { jest.spyOn(hooks, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: hooks.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx index 4b4bc2e8feeab..49fa3eab47862 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx @@ -35,7 +35,7 @@ export function CustomLinkOverview() { CustomLink | undefined >(); - const { data: customLinks = [], status, refetch } = useFetcher( + const { data, status, refetch } = useFetcher( async (callApmApi) => { if (hasValidLicense) { return callApmApi({ @@ -46,6 +46,8 @@ export function CustomLinkOverview() { [hasValidLicense] ); + const customLinks = data?.customLinks ?? []; + useEffect(() => { if (customLinkSelected) { setIsFlyoutOpen(true); diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 6a11f862994e2..bf9062418313a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -21,6 +21,7 @@ import { EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { ML_ERRORS } from '../../../../../common/anomaly_detection'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; @@ -33,6 +34,10 @@ interface Props { onCreateJobSuccess: () => void; onCancel: () => void; } + +type ApiResponse = APIReturnType<'GET /api/apm/settings/anomaly-detection/environments'>; +const INITIAL_DATA: ApiResponse = { environments: [] }; + export function AddEnvironments({ currentEnvironments, onCreateJobSuccess, @@ -42,7 +47,7 @@ export function AddEnvironments({ const { anomalyDetectionJobsRefetch } = useAnomalyDetectionJobsContext(); const canCreateJob = !!application.capabilities.ml.canCreateJob; const { toasts } = notifications; - const { data = [], status } = useFetcher( + const { data = INITIAL_DATA, status } = useFetcher( (callApmApi) => callApmApi({ endpoint: `GET /api/apm/settings/anomaly-detection/environments`, @@ -51,7 +56,7 @@ export function AddEnvironments({ { preservePreviousData: false } ); - const environmentOptions = data.map((env) => ({ + const environmentOptions = data.environments.map((env) => ({ label: getEnvironmentLabel(env), value: env, disabled: currentEnvironments.includes(env), diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx index 66fb72975acea..f31354bc7aa3c 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx @@ -49,10 +49,10 @@ const Culprit = euiStyled.div` font-family: ${fontFamilyCode}; `; -type ErrorGroupListAPIResponse = APIReturnType<'GET /api/apm/services/{serviceName}/errors'>; +type ErrorGroupItem = APIReturnType<'GET /api/apm/services/{serviceName}/errors'>['errorGroups'][0]; interface Props { - items: ErrorGroupListAPIResponse; + items: ErrorGroupItem[]; serviceName: string; } @@ -128,7 +128,7 @@ function ErrorGroupList({ items, serviceName }: Props) { field: 'message', sortable: false, width: '50%', - render: (message: string, item: ErrorGroupListAPIResponse[0]) => { + render: (message: string, item: ErrorGroupItem) => { return ( diff --git a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx index f4870439fe478..fc218f3ba6df3 100644 --- a/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_node_overview/index.tsx @@ -39,7 +39,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { urlParams: { kuery, start, end }, } = useUrlParams(); - const { data: items = [] } = useFetcher( + const { data } = useFetcher( (callApmApi) => { if (!start || !end) { return undefined; @@ -61,6 +61,7 @@ function ServiceNodeOverview({ serviceName }: ServiceNodeOverviewProps) { [kuery, serviceName, start, end] ); + const items = data?.serviceNodes ?? []; const columns: Array> = [ { name: ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index a4647bc148b1e..4ff42b151dc8e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -164,7 +164,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { }, ]; - const { data = [], status } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (!start || !end) { return; @@ -188,8 +188,10 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { [start, end, serviceName, environment] ); + const serviceDependencies = data?.serviceDependencies ?? []; + // need top-level sortable fields for the managed table - const items = data.map((item) => ({ + const items = serviceDependencies.map((item) => ({ ...item, errorRateValue: item.errorRate.value, latencyValue: item.latency.value, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index c10ec1052f2a2..13322b094c65e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -12,6 +12,7 @@ import uuid from 'uuid'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceOverviewInstancesTable, @@ -30,20 +31,24 @@ interface ServiceOverviewInstancesChartAndTableProps { serviceName: string; } -const INITIAL_STATE = { - items: [] as Array<{ - serviceNodeName: string; - errorRate: number; - throughput: number; - latency: number; - cpuUsage: number; - memoryUsage: number; - }>, - requestId: undefined, - totalItems: 0, +export interface PrimaryStatsServiceInstanceItem { + serviceNodeName: string; + errorRate: number; + throughput: number; + latency: number; + cpuUsage: number; + memoryUsage: number; +} + +const INITIAL_STATE_PRIMARY_STATS = { + primaryStatsItems: [] as PrimaryStatsServiceInstanceItem[], + primaryStatsRequestId: undefined, + primaryStatsItemCount: 0, }; -const INITIAL_STATE_COMPARISON_STATISTICS = { +type ApiResponseComparisonStats = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; + +const INITIAL_STATE_COMPARISON_STATISTICS: ApiResponseComparisonStats = { currentPeriod: {}, previousPeriod: {}, }; @@ -93,7 +98,10 @@ export function ServiceOverviewInstancesChartAndTable({ comparisonType, }); - const { data = INITIAL_STATE, status } = useFetcher( + const { + data: primaryStatsData = INITIAL_STATE_PRIMARY_STATS, + status: primaryStatsStatus, + } = useFetcher( (callApmApi) => { if (!start || !end || !transactionType || !latencyAggregationType) { return; @@ -116,9 +124,9 @@ export function ServiceOverviewInstancesChartAndTable({ }, }, }).then((response) => { - const tableItems = orderBy( + const primaryStatsItems = orderBy( // need top-level sortable fields for the managed table - response.map((item) => ({ + response.serviceInstances.map((item) => ({ ...item, latency: item.latency ?? 0, throughput: item.throughput ?? 0, @@ -131,9 +139,9 @@ export function ServiceOverviewInstancesChartAndTable({ ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); return { - requestId: uuid(), - items: tableItems, - totalItems: response.length, + primaryStatsRequestId: uuid(), + primaryStatsItems, + primaryStatsItemCount: response.serviceInstances.length, }; }); }, @@ -154,10 +162,14 @@ export function ServiceOverviewInstancesChartAndTable({ ] ); - const { items, requestId, totalItems } = data; + const { + primaryStatsItems, + primaryStatsRequestId, + primaryStatsItemCount, + } = primaryStatsData; const { - data: comparisonStatistics = INITIAL_STATE_COMPARISON_STATISTICS, + data: comparisonStatsData = INITIAL_STATE_COMPARISON_STATISTICS, status: comparisonStatisticsStatus, } = useFetcher( (callApmApi) => { @@ -166,7 +178,7 @@ export function ServiceOverviewInstancesChartAndTable({ !end || !transactionType || !latencyAggregationType || - !totalItems + !primaryStatsItemCount ) { return; } @@ -187,7 +199,7 @@ export function ServiceOverviewInstancesChartAndTable({ numBuckets: 20, transactionType, serviceNodeIds: JSON.stringify( - items.map((item) => item.serviceNodeName) + primaryStatsItems.map((item) => item.serviceNodeName) ), comparisonStart, comparisonEnd, @@ -197,7 +209,7 @@ export function ServiceOverviewInstancesChartAndTable({ }, // only fetches comparison statistics when requestId is invalidated by primary statistics api call // eslint-disable-next-line react-hooks/exhaustive-deps - [requestId], + [primaryStatsRequestId], { preservePreviousData: false } ); @@ -213,14 +225,14 @@ export function ServiceOverviewInstancesChartAndTable({ { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx index f2a169eb31f98..b88172a162063 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/get_columns.tsx @@ -8,7 +8,6 @@ import { EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { ValuesType } from 'utility-types'; import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { isJavaAgentName } from '../../../../../common/agent_name'; import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; @@ -25,10 +24,7 @@ import { MetricOverviewLink } from '../../../shared/Links/apm/MetricOverviewLink import { ServiceNodeMetricOverviewLink } from '../../../shared/Links/apm/ServiceNodeMetricOverviewLink'; import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { getLatencyColumnLabel } from '../get_latency_column_label'; - -type ServiceInstancePrimaryStatisticItem = ValuesType< - APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics'> ->; +import { PrimaryStatsServiceInstanceItem } from '../service_overview_instances_chart_and_table'; type ServiceInstanceComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; @@ -36,15 +32,15 @@ export function getColumns({ serviceName, agentName, latencyAggregationType, - serviceInstanceComparisonStatistics, + comparisonStatsData, comparisonEnabled, }: { serviceName: string; agentName?: string; latencyAggregationType?: LatencyAggregationType; - serviceInstanceComparisonStatistics?: ServiceInstanceComparisonStatistics; + comparisonStatsData?: ServiceInstanceComparisonStatistics; comparisonEnabled?: boolean; -}): Array> { +}): Array> { return [ { field: 'serviceNodeName', @@ -91,11 +87,9 @@ export function getColumns({ width: px(unit * 10), render: (_, { serviceNodeName, latency }) => { const currentPeriodTimestamp = - serviceInstanceComparisonStatistics?.currentPeriod?.[serviceNodeName] - ?.latency; + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.latency; const previousPeriodTimestamp = - serviceInstanceComparisonStatistics?.previousPeriod?.[serviceNodeName] - ?.latency; + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.latency; return ( { const currentPeriodTimestamp = - serviceInstanceComparisonStatistics?.currentPeriod?.[serviceNodeName] - ?.throughput; + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.throughput; const previousPeriodTimestamp = - serviceInstanceComparisonStatistics?.previousPeriod?.[serviceNodeName] - ?.throughput; + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.throughput; return ( { const currentPeriodTimestamp = - serviceInstanceComparisonStatistics?.currentPeriod?.[serviceNodeName] - ?.errorRate; + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.errorRate; const previousPeriodTimestamp = - serviceInstanceComparisonStatistics?.previousPeriod?.[serviceNodeName] - ?.errorRate; + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.errorRate; return ( { const currentPeriodTimestamp = - serviceInstanceComparisonStatistics?.currentPeriod?.[serviceNodeName] - ?.cpuUsage; + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.cpuUsage; const previousPeriodTimestamp = - serviceInstanceComparisonStatistics?.previousPeriod?.[serviceNodeName] - ?.cpuUsage; + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.cpuUsage; return ( { const currentPeriodTimestamp = - serviceInstanceComparisonStatistics?.currentPeriod?.[serviceNodeName] - ?.memoryUsage; + comparisonStatsData?.currentPeriod?.[serviceNodeName]?.memoryUsage; const previousPeriodTimestamp = - serviceInstanceComparisonStatistics?.previousPeriod?.[serviceNodeName] - ?.memoryUsage; + comparisonStatsData?.previousPeriod?.[serviceNodeName]?.memoryUsage; return ( ->; - type ServiceInstanceComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/comparison_statistics'>; export interface TableOptions { @@ -42,26 +38,26 @@ export interface TableOptions { } interface Props { - items?: ServiceInstanceItem[]; + primaryStatsItems: PrimaryStatsServiceInstanceItem[]; serviceName: string; - status: FETCH_STATUS; - totalItems: number; + primaryStatsStatus: FETCH_STATUS; + primaryStatsItemCount: number; tableOptions: TableOptions; onChangeTableOptions: (newTableOptions: { page?: { index: number }; sort?: { field: string; direction: SortDirection }; }) => void; - serviceInstanceComparisonStatistics?: ServiceInstanceComparisonStatistics; + comparisonStatsData?: ServiceInstanceComparisonStatistics; isLoading: boolean; } export function ServiceOverviewInstancesTable({ - items = [], - totalItems, + primaryStatsItems = [], + primaryStatsItemCount, serviceName, - status, + primaryStatsStatus: status, tableOptions, onChangeTableOptions, - serviceInstanceComparisonStatistics, + comparisonStatsData: comparisonStatsData, isLoading, }: Props) { const { agentName } = useApmServiceContext(); @@ -76,14 +72,14 @@ export function ServiceOverviewInstancesTable({ agentName, serviceName, latencyAggregationType, - serviceInstanceComparisonStatistics, + comparisonStatsData, comparisonEnabled, }); const pagination = { pageIndex, pageSize: PAGE_SIZE, - totalItemCount: totalItems, + totalItemCount: primaryStatsItemCount, hidePerPageOptions: true, }; @@ -101,11 +97,11 @@ export function ServiceOverviewInstancesTable({ ; const INITIAL_STATE = { - transactionGroups: [], + transactionGroups: [] as ApiResponse['transactionGroups'], isAggregationAccurate: true, requestId: '', transactionGroupsTotalItems: 0, diff --git a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx index 23adbb23b2322..94391b5b2fb06 100644 --- a/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_profiling/index.tsx @@ -19,6 +19,7 @@ import { } from '../../../../common/profiling'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { SearchBar } from '../../shared/search_bar'; import { ServiceProfilingFlamegraph } from './service_profiling_flamegraph'; import { ServiceProfilingTimeline } from './service_profiling_timeline'; @@ -28,6 +29,9 @@ interface ServiceProfilingProps { environment?: string; } +type ApiResponse = APIReturnType<'GET /api/apm/services/{serviceName}/profiling/timeline'>; +const DEFAULT_DATA: ApiResponse = { profilingTimeline: [] }; + export function ServiceProfiling({ serviceName, environment, @@ -36,7 +40,7 @@ export function ServiceProfiling({ urlParams: { kuery, start, end }, } = useUrlParams(); - const { data = [] } = useFetcher( + const { data = DEFAULT_DATA } = useFetcher( (callApmApi) => { if (!start || !end) { return; @@ -58,14 +62,16 @@ export function ServiceProfiling({ [kuery, start, end, serviceName, environment] ); + const { profilingTimeline } = data; + const [valueType, setValueType] = useState(); useEffect(() => { - if (!data.length) { + if (!profilingTimeline.length) { return; } - const availableValueTypes = data.reduce((set, point) => { + const availableValueTypes = profilingTimeline.reduce((set, point) => { (Object.keys(point.valueTypes).filter( (type) => type !== 'unknown' ) as ProfilingValueType[]) @@ -80,7 +86,7 @@ export function ServiceProfiling({ if (!valueType || !availableValueTypes.has(valueType)) { setValueType(Array.from(availableValueTypes)[0]); } - }, [data, valueType]); + }, [profilingTimeline, valueType]); return ( <> @@ -103,7 +109,7 @@ export function ServiceProfiling({ { setValueType(type); }} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx index 3cd858aceaa90..4bc9764b704b0 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/apm_header.stories.tsx @@ -8,7 +8,7 @@ import { EuiTitle } from '@elastic/eui'; import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { HttpSetup } from '../../../../../../../src/core/public'; +import { CoreStart } from '../../../../../../../src/core/public'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; @@ -20,7 +20,7 @@ export default { component: ApmHeader, decorators: [ (Story: ComponentType) => { - createCallApmApi(({} as unknown) as HttpSetup); + createCallApmApi(({} as unknown) as CoreStart); return ( diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx index 6f2910a2a5ef7..a624c220a0e4c 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.test.tsx @@ -9,7 +9,6 @@ import { act, fireEvent, render } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CustomLinkMenuSection } from '.'; -import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import * as useFetcher from '../../../../hooks/use_fetcher'; @@ -40,7 +39,7 @@ const transaction = ({ describe('Custom links', () => { it('shows empty message when no custom link is available', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -58,7 +57,7 @@ describe('Custom links', () => { it('shows loading while custom links are fetched', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.LOADING, refetch: jest.fn(), }); @@ -71,12 +70,14 @@ describe('Custom links', () => { }); it('shows first 3 custom links available', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const customLinks = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ data: customLinks, @@ -93,15 +94,17 @@ describe('Custom links', () => { }); it('clicks "show all" and "show fewer"', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const data = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: customLinks, + data, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -125,7 +128,7 @@ describe('Custom links', () => { describe('create custom link buttons', () => { it('shows create button below empty message', () => { jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: [], + data: { customLinks: [] }, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); @@ -140,15 +143,17 @@ describe('Custom links', () => { }); it('shows create button besides the title', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' }, - ] as CustomLinkType[]; + const data = { + customLinks: [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' }, + ], + }; jest.spyOn(useFetcher, 'useFetcher').mockReturnValue({ - data: customLinks, + data, status: useFetcher.FETCH_STATUS.SUCCESS, refetch: jest.fn(), }); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx index 7d2e4a13278ec..cbbf34c78c4af 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx @@ -58,7 +58,7 @@ export function CustomLinkMenuSection({ [transaction] ); - const { data: customLinks = [], status, refetch } = useFetcher( + const { data, status, refetch } = useFetcher( (callApmApi) => callApmApi({ isCachable: false, @@ -68,6 +68,8 @@ export function CustomLinkMenuSection({ [filters] ); + const customLinks = data?.customLinks ?? []; + return ( <> {isCreateEditFlyoutOpen && ( diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts index b0ac35cc3667a..b8d67f71a9baa 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.test.ts @@ -7,7 +7,7 @@ import { onBrushEnd, isTimeseriesEmpty } from './helper'; import { History } from 'history'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; describe('Chart helper', () => { describe('onBrushEnd', () => { @@ -52,7 +52,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns true when y coordinate is null', () => { @@ -63,7 +63,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns true when y coordinate is undefined', () => { @@ -74,7 +74,7 @@ describe('Chart helper', () => { type: 'line', color: 'red', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeTruthy(); }); it('returns false when at least one coordinate is filled', () => { @@ -91,7 +91,7 @@ describe('Chart helper', () => { type: 'line', color: 'green', }, - ] as TimeSeries[]; + ] as Array>; expect(isTimeseriesEmpty(timeseries)).toBeFalsy(); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts index 3b93cb1f402e8..d94f2ce8f5c5d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/helper/helper.ts @@ -7,7 +7,7 @@ import { XYBrushArea } from '@elastic/charts'; import { History } from 'history'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { fromQuery, toQuery } from '../../Links/url_helpers'; export const onBrushEnd = ({ @@ -36,15 +36,12 @@ export const onBrushEnd = ({ } }; -export function isTimeseriesEmpty(timeseries?: TimeSeries[]) { +export function isTimeseriesEmpty(timeseries?: Array>) { return ( !timeseries || timeseries .map((serie) => serie.data) .flat() - .every( - ({ y }: { x?: number | null; y?: number | null }) => - y === null || y === undefined - ) + .every(({ y }: Coordinate) => y === null || y === undefined) ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 5f24c1ee2495b..5bcf0d161653e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -23,13 +23,13 @@ import { } from '../../../../../common/utils/formatters'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { PrimaryStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; import { ChartContainer } from '../chart_container'; import { getResponseTimeTickFormatter } from '../transaction_charts/helper'; interface InstancesLatencyDistributionChartProps { height: number; - items?: APIReturnType<'GET /api/apm/services/{serviceName}/service_overview_instances/primary_statistics'>; + items?: PrimaryStatsServiceInstanceItem[]; status: FETCH_STATUS; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx index 8009f288d48c0..2c9601d709cb4 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/timeseries_chart.tsx @@ -28,7 +28,11 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; import { useChartTheme } from '../../../../../observability/public'; import { asAbsoluteDateTime } from '../../../../common/utils/formatters'; -import { RectCoordinate, TimeSeries } from '../../../../typings/timeseries'; +import { + Coordinate, + RectCoordinate, + TimeSeries, +} from '../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useTheme } from '../../../hooks/use_theme'; import { useAnnotationsContext } from '../../../context/annotations/use_annotations_context'; @@ -43,7 +47,7 @@ interface Props { fetchStatus: FETCH_STATUS; height?: number; onToggleLegend?: LegendItemListener; - timeseries: TimeSeries[]; + timeseries: Array>; /** * Formatter for y-axis tick values */ @@ -85,12 +89,10 @@ export function TimeseriesChart({ const max = Math.max(...xValues); const xFormatter = niceTimeFormatter([min, max]); - const isEmpty = isTimeseriesEmpty(timeseries); - const annotationColor = theme.eui.euiColorSecondary; - const allSeries = [...timeseries, ...(anomalyTimeseries?.boundaries ?? [])]; + const xDomain = isEmpty ? { min: 0, max: 1 } : { min, max }; return ( @@ -111,7 +113,7 @@ export function TimeseriesChart({ showLegend showLegendExtra legendPosition={Position.Bottom} - xDomain={{ min, max }} + xDomain={xDomain} onLegendItemClick={(legend) => { if (onToggleLegend) { onToggleLegend(legend); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx index f55389ec2d5f7..23016cc5dd8e9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/transaction_breakdown_chart_contents.tsx @@ -28,7 +28,7 @@ import { asAbsoluteDateTime, asPercent, } from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { useTheme } from '../../../../hooks/use_theme'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; @@ -42,7 +42,7 @@ interface Props { fetchStatus: FETCH_STATUS; height?: number; showAnnotations: boolean; - timeseries?: TimeSeries[]; + timeseries?: Array>; } export function TransactionBreakdownChartContents({ diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx index 6c46580f4738e..31d18b7a9709d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx @@ -6,14 +6,14 @@ */ import { isFiniteNumber } from '../../../../../common/utils/is_finite_number'; -import { APMChartSpec, Coordinate } from '../../../../../typings/timeseries'; +import { Coordinate } from '../../../../../typings/timeseries'; import { TimeFormatter } from '../../../../../common/utils/formatters'; export function getResponseTimeTickFormatter(formatter: TimeFormatter) { return (t: number) => formatter(t).formatted; } -export function getMaxY(specs?: Array>) { +export function getMaxY(specs?: Array<{ data: Coordinate[] }>) { const values = specs ?.flatMap((spec) => spec.data) .map((coord) => coord.y) diff --git a/x-pack/plugins/apm/public/components/shared/search_bar.tsx b/x-pack/plugins/apm/public/components/shared/search_bar.tsx index 2bd3fef8c0e88..1018b9eca2119 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -7,14 +7,21 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; +import { enableInspectEsQueries } from '../../../../observability/public'; import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { px, unit } from '../../style/variables'; import { DatePicker } from './DatePicker'; import { KueryBar } from './KueryBar'; import { TimeComparison } from './time_comparison'; import { useBreakPoints } from '../../hooks/use_break_points'; +import { useKibanaUrl } from '../../hooks/useKibanaUrl'; +import { useApmPluginContext } from '../../context/apm_plugin/use_apm_plugin_context'; -const SearchBarFlexGroup = euiStyled(EuiFlexGroup)` +const EuiFlexGroupSpaced = euiStyled(EuiFlexGroup)` margin: ${({ theme }) => `${theme.eui.euiSizeS} ${theme.eui.euiSizeS} -${theme.eui.gutterTypes.gutterMedium} ${theme.eui.euiSizeS}`}; `; @@ -29,6 +36,52 @@ function getRowDirection(showColumn: boolean) { return showColumn ? 'column' : 'row'; } +function DebugQueryCallout() { + const { uiSettings } = useApmPluginContext().core; + const advancedSettingsUrl = useKibanaUrl('/app/management/kibana/settings', { + query: { + query: 'category:(observability)', + }, + }); + + if (!uiSettings.get(enableInspectEsQueries)) { + return null; + } + + return ( + + + + + {i18n.translate( + 'xpack.apm.searchBar.inspectEsQueriesEnabled.callout.description.advancedSettings', + { defaultMessage: 'Advanced Setting' } + )} + + ), + }} + /> + + + + ); +} + export function SearchBar({ prepend, showTimeComparison = false, @@ -38,26 +91,29 @@ export function SearchBar({ const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; return ( - - - - - - - {showTimeComparison && ( - - + <> + + + + + + + + {showTimeComparison && ( + + + + )} + + - )} - - - - - - + + + + ); } diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 024deca558497..9a910787d5fe8 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -116,8 +116,8 @@ export function MockApmPluginContextWrapper({ children?: React.ReactNode; value?: ApmPluginContextValue; }) { - if (value.core?.http) { - createCallApmApi(value.core?.http); + if (value.core) { + createCallApmApi(value.core); } return ( { if (start && end) { return callApmApi({ @@ -51,9 +53,9 @@ export function useEnvironmentsFetcher({ ); const environmentOptions = useMemo( - () => getEnvironmentOptions(environments), - [environments] + () => getEnvironmentOptions(data.environments), + [data?.environments] ); - return { environments, status, environmentOptions }; + return { environments: data.environments, status, environmentOptions }; } diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 5740e47d0076f..382053f133950 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -85,19 +85,19 @@ export class ApmPlugin implements Plugin { const getApmDataHelper = async () => { const { fetchObservabilityOverviewPageData, - hasData, + getHasData, createCallApmApi, } = await import('./services/rest/apm_observability_overview_fetchers'); // have to do this here as well in case app isn't mounted yet - createCallApmApi(core.http); + createCallApmApi(core); - return { fetchObservabilityOverviewPageData, hasData }; + return { fetchObservabilityOverviewPageData, getHasData }; }; plugins.observability.dashboard.register({ appName: 'apm', hasData: async () => { const dataHelper = await getApmDataHelper(); - return await dataHelper.hasData(); + return await dataHelper.getHasData(); }, fetchData: async (params: FetchDataParams) => { const dataHelper = await getApmDataHelper(); @@ -112,7 +112,7 @@ export class ApmPlugin implements Plugin { createCallApmApi, } = await import('./components/app/RumDashboard/ux_overview_fetchers'); // have to do this here as well in case app isn't mounted yet - createCallApmApi(core.http); + createCallApmApi(core); return { fetchUxOverviewDate, hasRumData }; }; diff --git a/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts index f9e72bff231f4..f334212536778 100644 --- a/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts @@ -8,14 +8,14 @@ import { difference, zipObject } from 'lodash'; import { EuiTheme } from '../../../../../src/plugins/kibana_react/common'; import { asTransactionRate } from '../../common/utils/formatters'; -import { TimeSeries } from '../../typings/timeseries'; +import { Coordinate, TimeSeries } from '../../typings/timeseries'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; export type ThroughputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; export interface ThroughputChart { - throughputTimeseries: TimeSeries[]; + throughputTimeseries: Array>; } export function getThroughputChartSelector({ diff --git a/x-pack/plugins/apm/public/services/callApi.test.ts b/x-pack/plugins/apm/public/services/callApi.test.ts index cdd9cb5b08a32..5f0be1b6fadbb 100644 --- a/x-pack/plugins/apm/public/services/callApi.test.ts +++ b/x-pack/plugins/apm/public/services/callApi.test.ts @@ -7,49 +7,51 @@ import { mockNow } from '../utils/testHelpers'; import { clearCache, callApi } from './rest/callApi'; -import { SessionStorageMock } from './__mocks__/SessionStorageMock'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart, HttpSetup } from 'kibana/public'; -type HttpMock = HttpSetup & { - get: jest.SpyInstance; +type CoreMock = CoreStart & { + http: { + get: jest.SpyInstance; + }; }; describe('callApi', () => { - let http: HttpMock; + let core: CoreMock; beforeEach(() => { - http = ({ - get: jest.fn().mockReturnValue({ - my_key: 'hello_world', - }), - } as unknown) as HttpMock; - - // @ts-expect-error - global.sessionStorage = new SessionStorageMock(); + core = ({ + http: { + get: jest.fn().mockReturnValue({ + my_key: 'hello_world', + }), + }, + uiSettings: { get: () => false }, // disable `observability:enableInspectEsQueries` setting + } as unknown) as CoreMock; }); afterEach(() => { - http.get.mockClear(); + core.http.get.mockClear(); clearCache(); }); - describe('apm_debug', () => { + describe('_inspect', () => { beforeEach(() => { - sessionStorage.setItem('apm_debug', 'true'); + // @ts-expect-error + core.uiSettings.get = () => true; // enable `observability:enableInspectEsQueries` setting }); it('should add debug param for APM endpoints', async () => { - await callApi(http, { pathname: `/api/apm/status/server` }); + await callApi(core, { pathname: `/api/apm/status/server` }); - expect(http.get).toHaveBeenCalledWith('/api/apm/status/server', { - query: { _debug: true }, + expect(core.http.get).toHaveBeenCalledWith('/api/apm/status/server', { + query: { _inspect: true }, }); }); it('should not add debug param for non-APM endpoints', async () => { - await callApi(http, { pathname: `/api/kibana` }); + await callApi(core, { pathname: `/api/kibana` }); - expect(http.get).toHaveBeenCalledWith('/api/kibana', { query: {} }); + expect(core.http.get).toHaveBeenCalledWith('/api/kibana', { query: {} }); }); }); @@ -65,138 +67,138 @@ describe('callApi', () => { describe('when the call does not contain start/end params', () => { it('should not return cached response for identical calls', async () => { - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - await callApi(http, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); + await callApi(core, { pathname: `/api/kibana`, query: { foo: 'bar' } }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); }); describe('when the call contains start/end params', () => { it('should return cached response for identical calls', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should not return cached response for subsequent calls if arguments change', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar1' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar2' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2010', end: '2011', foo: 'bar3' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should not return cached response if `end` is a future timestamp', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2030' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should return cached response if calls contain `end` param in the past', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should return cached response even if order of properties change', async () => { - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { end: '2010', start: '2009' }, }); - await callApi(http, { + await callApi(core, { pathname: `/api/kibana`, query: { start: '2009', end: '2010' }, }); - await callApi(http, { + await callApi(core, { query: { start: '2009', end: '2010' }, pathname: `/api/kibana`, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); it('should not return cached response with `isCachable: false` option', async () => { - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - await callApi(http, { + await callApi(core, { isCachable: false, pathname: `/api/kibana`, query: { start: '2010', end: '2011' }, }); - expect(http.get).toHaveBeenCalledTimes(3); + expect(core.http.get).toHaveBeenCalledTimes(3); }); it('should return cached response with `isCachable: true` option', async () => { - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - await callApi(http, { + await callApi(core, { isCachable: true, pathname: `/api/kibana`, query: { end: '2030' }, }); - expect(http.get).toHaveBeenCalledTimes(1); + expect(core.http.get).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/apm/public/services/callApmApi.test.ts b/x-pack/plugins/apm/public/services/callApmApi.test.ts index 25d34b5d102f5..56146c49fc57d 100644 --- a/x-pack/plugins/apm/public/services/callApmApi.test.ts +++ b/x-pack/plugins/apm/public/services/callApmApi.test.ts @@ -7,7 +7,7 @@ import * as callApiExports from './rest/callApi'; import { createCallApmApi, callApmApi } from './rest/createCallApmApi'; -import { HttpSetup } from 'kibana/public'; +import { CoreStart } from 'kibana/public'; const callApi = jest .spyOn(callApiExports, 'callApi') @@ -15,7 +15,7 @@ const callApi = jest describe('callApmApi', () => { beforeEach(() => { - createCallApmApi({} as HttpSetup); + createCallApmApi({} as CoreStart); }); afterEach(() => { @@ -79,7 +79,7 @@ describe('callApmApi', () => { {}, expect.objectContaining({ pathname: '/api/apm', - method: 'POST', + method: 'post', body: { foo: 'bar', bar: 'foo', diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts index b0bae6aa91a3d..1821e92ee5a78 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts @@ -8,7 +8,7 @@ import moment from 'moment'; import { fetchObservabilityOverviewPageData, - hasData, + getHasData, } from './apm_observability_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; @@ -31,12 +31,12 @@ describe('Observability dashboard data', () => { describe('hasData', () => { it('returns false when no data is available', async () => { callApmApiMock.mockImplementation(() => Promise.resolve(false)); - const response = await hasData(); + const response = await getHasData(); expect(response).toBeFalsy(); }); it('returns true when data is available', async () => { - callApmApiMock.mockImplementation(() => Promise.resolve(true)); - const response = await hasData(); + callApmApiMock.mockResolvedValue({ hasData: true }); + const response = await getHasData(); expect(response).toBeTruthy(); }); }); diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts index 6d630ede1cb11..55ead8d942aca 100644 --- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.ts @@ -58,9 +58,11 @@ export const fetchObservabilityOverviewPageData = async ({ }; }; -export async function hasData() { - return await callApmApi({ +export async function getHasData() { + const res = await callApmApi({ endpoint: 'GET /api/apm/observability_overview/has_data', signal: null, }); + + return res.hasData; } diff --git a/x-pack/plugins/apm/public/services/rest/callApi.ts b/x-pack/plugins/apm/public/services/rest/callApi.ts index f5106fce78cc7..f623872303c5b 100644 --- a/x-pack/plugins/apm/public/services/rest/callApi.ts +++ b/x-pack/plugins/apm/public/services/rest/callApi.ts @@ -5,15 +5,19 @@ * 2.0. */ -import { HttpSetup } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; import { isString, startsWith } from 'lodash'; import LRU from 'lru-cache'; import hash from 'object-hash'; +import { enableInspectEsQueries } from '../../../../observability/public'; import { FetchOptions } from '../../../common/fetch_options'; -function fetchOptionsWithDebug(fetchOptions: FetchOptions) { +function fetchOptionsWithDebug( + fetchOptions: FetchOptions, + inspectableEsQueriesEnabled: boolean +) { const debugEnabled = - sessionStorage.getItem('apm_debug') === 'true' && + inspectableEsQueriesEnabled && startsWith(fetchOptions.pathname, '/api/apm'); const { body, ...rest } = fetchOptions; @@ -23,7 +27,7 @@ function fetchOptionsWithDebug(fetchOptions: FetchOptions) { ...(body !== undefined ? { body: JSON.stringify(body) } : {}), query: { ...fetchOptions.query, - ...(debugEnabled ? { _debug: true } : {}), + ...(debugEnabled ? { _inspect: true } : {}), }, }; } @@ -37,9 +41,12 @@ export function clearCache() { export type CallApi = typeof callApi; export async function callApi( - http: HttpSetup, + { http, uiSettings }: CoreStart | CoreSetup, fetchOptions: FetchOptions ): Promise { + const inspectableEsQueriesEnabled: boolean = uiSettings.get( + enableInspectEsQueries + ); const cacheKey = getCacheKey(fetchOptions); const cacheResponse = cache.get(cacheKey); if (cacheResponse) { @@ -47,7 +54,8 @@ export async function callApi( } const { pathname, method = 'get', ...options } = fetchOptionsWithDebug( - fetchOptions + fetchOptions, + inspectableEsQueriesEnabled ); const lowercaseMethod = method.toLowerCase() as diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index c6d55a85dd70e..b0cce3296fe21 100644 --- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { HttpSetup } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { parseEndpoint } from '../../../common/apm_api/parse_endpoint'; import { FetchOptions } from '../../../common/fetch_options'; import { callApi } from './callApi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { APMAPI } from '../../../server/routes/create_apm_api'; +import type { APMAPI } from '../../../server/routes/create_apm_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Client } from '../../../server/routes/typings'; +import type { Client } from '../../../server/routes/typings'; export type APMClient = Client; export type AutoAbortedAPMClient = Client; @@ -24,8 +25,8 @@ export type APMClientOptions = Omit< signal: AbortSignal | null; params?: { body?: any; - query?: any; - path?: any; + query?: Record; + path?: Record; }; }; @@ -35,23 +36,17 @@ export let callApmApi: APMClient = () => { ); }; -export function createCallApmApi(http: HttpSetup) { +export function createCallApmApi(core: CoreStart | CoreSetup) { callApmApi = ((options: APMClientOptions) => { - const { endpoint, params = {}, ...opts } = options; + const { endpoint, params, ...opts } = options; + const { method, pathname } = parseEndpoint(endpoint, params?.path); - const path = (params.path || {}) as Record; - const [method, pathname] = endpoint.split(' '); - - const formattedPathname = Object.keys(path).reduce((acc, paramName) => { - return acc.replace(`{${paramName}}`, path[paramName]); - }, pathname); - - return callApi(http, { + return callApi(core, { ...opts, method, - pathname: formattedPathname, - body: params.body, - query: params.query, + pathname, + body: params?.body, + query: params?.query, }); }) as APMClient; } diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index b125407a160aa..ef2675f4f6c65 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -160,10 +160,10 @@ The users will be created with the password specified in kibana.dev.yml for `ela ## Debugging Elasticsearch queries -All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. +All APM api endpoints accept `_inspect=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. Example: -`/api/apm/services/my_service?_debug=true` +`/api/apm/services/my_service?_inspect=true` ## Storybook diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts index 50613c10ff7c0..88b1cf3a344ed 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts @@ -106,7 +106,7 @@ export async function getLatencyDistribution({ type Agg = NonNullable; if (!response.aggregations) { - return; + return {}; } function formatDistribution(distribution: Agg['distribution']) { diff --git a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts index 94ed3dc3b6999..cc1e32e47973d 100644 --- a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts +++ b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts @@ -47,10 +47,15 @@ function getMaxImpactScore(scores: number[]) { export function processSignificantTermAggs({ sigTermAggs, }: { - sigTermAggs: Record; + sigTermAggs: Record; }) { - const significantTerms = Object.entries(sigTermAggs).flatMap( - ([fieldName, agg]) => { + const significantTerms = Object.entries(sigTermAggs) + // filter entries with buckets, i.e. Significant terms aggs + .filter((entry): entry is [string, SigTermAgg] => { + const [, agg] = entry; + return 'buckets' in agg; + }) + .flatMap(([fieldName, agg]) => { return agg.buckets.map((bucket) => ({ fieldName, fieldValue: bucket.key, @@ -58,8 +63,7 @@ export function processSignificantTermAggs({ valueCount: bucket.doc_count, score: bucket.score, })); - } - ); + }); const maxImpactScore = getMaxImpactScore( significantTerms.map(({ score }) => score) diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts index aa41880fba444..1f0aa401bcab0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/call_async_with_debug.ts @@ -7,8 +7,10 @@ /* eslint-disable no-console */ +import { omit } from 'lodash'; import chalk from 'chalk'; import { KibanaRequest } from '../../../../../../../src/core/server'; +import { inspectableEsQueriesMap } from '../../../routes/create_api'; function formatObj(obj: Record) { return JSON.stringify(obj, null, 2); @@ -18,10 +20,18 @@ export async function callAsyncWithDebug({ cb, getDebugMessage, debug, + request, + requestType, + requestParams, + isCalledWithInternalUser, }: { cb: () => Promise; getDebugMessage: () => { body: string; title: string }; debug: boolean; + request: KibanaRequest; + requestType: string; + requestParams: Record; + isCalledWithInternalUser: boolean; // only allow inspection of queries that were retrieved with credentials of the end user }) { if (!debug) { return cb(); @@ -41,16 +51,27 @@ export async function callAsyncWithDebug({ if (debug) { const highlightColor = esError ? 'bgRed' : 'inverse'; const diff = process.hrtime(startTime); - const duration = `${Math.round(diff[0] * 1000 + diff[1] / 1e6)}ms`; + const duration = Math.round(diff[0] * 1000 + diff[1] / 1e6); // duration in ms const { title, body } = getDebugMessage(); console.log( - chalk.bold[highlightColor](`=== Debug: ${title} (${duration}) ===`) + chalk.bold[highlightColor](`=== Debug: ${title} (${duration}ms) ===`) ); console.log(body); console.log(`\n`); + + const inspectableEsQueries = inspectableEsQueriesMap.get(request); + if (!isCalledWithInternalUser && inspectableEsQueries) { + inspectableEsQueries.push({ + response: res, + duration, + requestType, + requestParams: omit(requestParams, 'headers'), + esError: esError?.response ?? esError?.message, + }); + } } if (esError) { @@ -62,13 +83,13 @@ export async function callAsyncWithDebug({ export const getDebugBody = ( params: Record, - operationName: string + requestType: string ) => { - if (operationName === 'search') { + if (requestType === 'search') { return `GET ${params.index}/_search\n${formatObj(params.body)}`; } - return `${chalk.bold('ES operation:')} ${operationName}\n${chalk.bold( + return `${chalk.bold('ES operation:')} ${requestType}\n${chalk.bold( 'ES query:' )}\n${formatObj(params)}`; }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index e20103cc6ddca..b8a14253a229a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -93,6 +93,9 @@ export function createApmEventClient({ ignore_unavailable: true, }; + // only "search" operation is currently supported + const requestType = 'search'; + return callAsyncWithDebug({ cb: () => { const searchPromise = cancelEsRequestOnAbort( @@ -103,10 +106,14 @@ export function createApmEventClient({ return unwrapEsResponse(searchPromise); }, getDebugMessage: () => ({ - body: getDebugBody(searchParams, 'search'), + body: getDebugBody(searchParams, requestType), title: getDebugTitle(request), }), + isCalledWithInternalUser: false, debug, + request, + requestType, + requestParams: searchParams, }); }, }; diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts index 2e83baece01a9..45e17c1678518 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_internal_es_client/index.ts @@ -40,10 +40,10 @@ export function createInternalESClient({ function callEs({ cb, - operationName, + requestType, params, }: { - operationName: string; + requestType: string; cb: () => TransportRequestPromise; params: Record; }) { @@ -51,9 +51,13 @@ export function createInternalESClient({ cb: () => unwrapEsResponse(cancelEsRequestOnAbort(cb(), request)), getDebugMessage: () => ({ title: getDebugTitle(request), - body: getDebugBody(params, operationName), + body: getDebugBody(params, requestType), }), - debug: context.params.query._debug, + debug: context.params.query._inspect, + isCalledWithInternalUser: true, + request, + requestType, + requestParams: params, }); } @@ -65,28 +69,28 @@ export function createInternalESClient({ params: TSearchRequest ): Promise> => { return callEs({ - operationName: 'search', + requestType: 'search', cb: () => asInternalUser.search(params), params, }); }, index: (params: APMIndexDocumentParams) => { return callEs({ - operationName: 'index', + requestType: 'index', cb: () => asInternalUser.index(params), params, }); }, delete: (params: DeleteRequest): Promise<{ result: string }> => { return callEs({ - operationName: 'delete', + requestType: 'delete', cb: () => asInternalUser.delete(params), params, }); }, indicesCreate: (params: CreateIndexRequest) => { return callEs({ - operationName: 'indices.create', + requestType: 'indices.create', cb: () => asInternalUser.indices.create(params), params, }); diff --git a/x-pack/plugins/apm/server/lib/helpers/input_validation.ts b/x-pack/plugins/apm/server/lib/helpers/input_validation.ts index 5c188ff0d093e..0a34711b9b40d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/input_validation.ts +++ b/x-pack/plugins/apm/server/lib/helpers/input_validation.ts @@ -14,7 +14,7 @@ export const withDefaultValidators = ( validators: { [key: string]: Schema } = {} ) => { return Joi.object().keys({ - _debug: Joi.bool(), + _inspect: Joi.bool(), start: dateValidation, end: dateValidation, uiFilters: Joi.string(), diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 51f386d59c04a..c0707d0286180 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -51,7 +51,7 @@ function getMockRequest() { ) as APMConfig, params: { query: { - _debug: false, + _inspect: false, }, }, core: { diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 60fb9a8bfa85a..fff661250c6df 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -45,7 +45,7 @@ export interface SetupTimeRange { interface SetupRequestParams { query?: { - _debug?: boolean; + _inspect?: boolean; /** * Timestamp in ms since epoch @@ -88,7 +88,7 @@ export async function setupRequest( indices, apmEventClient: createApmEventClient({ esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._debug, + debug: context.params.query._inspect, request, indices, options: { includeFrozen }, diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index 8d0acb7f85f5d..0b7f82c0b8388 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -21,20 +21,20 @@ export async function createStaticIndexPattern( setup: Setup, context: APMRequestHandlerContext, savedObjectsClient: InternalSavedObjectsClient -): Promise { +): Promise { return withApmSpan('create_static_index_pattern', async () => { const { config } = context; // don't autocreate APM index pattern if it's been disabled via the config if (!config['xpack.apm.autocreateApmIndexPattern']) { - return; + return false; } // Discover and other apps will throw errors if an index pattern exists without having matching indices. // The following ensures the index pattern is only created if APM data is found const hasData = await hasHistoricalAgentData(setup); if (!hasData) { - return; + return false; } try { @@ -49,12 +49,12 @@ export async function createStaticIndexPattern( { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } ) ); - return; + return true; } catch (e) { // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown // that error should be silenced if (SavedObjectsErrorHelpers.isConflictError(e)) { - return; + return false; } throw e; } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts index abdc8da78502c..bbe13874d7d3b 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts @@ -9,7 +9,7 @@ import { ProcessorEvent } from '../../../common/processor_event'; import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; -export function hasData({ setup }: { setup: Setup }) { +export function getHasData({ setup }: { setup: Setup }) { return withApmSpan('observability_overview_has_apm_data', async () => { const { apmEventClient } = setup; try { diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts index 32f2238b0ddea..3bebcd49ec34a 100644 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts @@ -35,12 +35,14 @@ export const transactionErrorRateChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; + const { _inspect, ...alertParams } = context.params.query; - return getTransactionErrorRateChartPreview({ + const errorRateChartPreview = await getTransactionErrorRateChartPreview({ setup, alertParams, }); + + return { errorRateChartPreview }; }, }); @@ -50,11 +52,13 @@ export const transactionErrorCountChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; - return getTransactionErrorCountChartPreview({ + const { _inspect, ...alertParams } = context.params.query; + const errorCountChartPreview = await getTransactionErrorCountChartPreview({ setup, alertParams, }); + + return { errorCountChartPreview }; }, }); @@ -64,11 +68,13 @@ export const transactionDurationChartPreview = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; + const { _inspect, ...alertParams } = context.params.query; - return getTransactionDurationChartPreview({ + const latencyChartPreview = await getTransactionDurationChartPreview({ alertParams, setup, }); + + return { latencyChartPreview }; }, }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 01d2797641805..9958b8dec0124 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -48,6 +48,49 @@ const getCoreMock = () => { }; }; +const initApi = (params?: RouteParamsRT) => { + const { mock, context, createRouter, get, post } = getCoreMock(); + const handlerMock = jest.fn(); + createApi() + .add(() => ({ + endpoint: 'GET /foo', + params, + options: { tags: ['access:apm'] }, + handler: handlerMock, + })) + .init(mock, context); + + const routeHandler = get.mock.calls[0][1]; + + const responseMock = { + ok: jest.fn(), + custom: jest.fn(), + }; + + const simulateRequest = (requestMock: any) => { + return routeHandler( + {}, + { + // stub default values + params: {}, + query: {}, + body: null, + ...requestMock, + }, + responseMock + ); + }; + + return { + simulateRequest, + handlerMock, + createRouter, + get, + post, + responseMock, + }; +}; + describe('createApi', () => { it('registers a route with the server', () => { const { mock, context, createRouter, post, get, put } = getCoreMock(); @@ -56,7 +99,7 @@ describe('createApi', () => { .add(() => ({ endpoint: 'GET /foo', options: { tags: ['access:apm'] }, - handler: async () => null, + handler: async () => ({}), })) .add(() => ({ endpoint: 'POST /bar', @@ -64,21 +107,21 @@ describe('createApi', () => { body: t.string, }), options: { tags: ['access:apm'] }, - handler: async () => null, + handler: async () => ({}), })) .add(() => ({ endpoint: 'PUT /baz', options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async () => null, + handler: async () => ({}), })) .add({ endpoint: 'GET /qux', options: { tags: ['access:apm', 'access:apm_write'], }, - handler: async () => null, + handler: async () => ({}), }) .init(mock, context); @@ -122,102 +165,78 @@ describe('createApi', () => { }); describe('when validating', () => { - const initApi = (params?: RouteParamsRT) => { - const { mock, context, createRouter, get, post } = getCoreMock(); - const handlerMock = jest.fn(); - createApi() - .add(() => ({ - endpoint: 'GET /foo', - params, - options: { tags: ['access:apm'] }, - handler: handlerMock, - })) - .init(mock, context); - - const routeHandler = get.mock.calls[0][1]; - - const responseMock = { - ok: jest.fn(), - internalError: jest.fn(), - notFound: jest.fn(), - forbidden: jest.fn(), - badRequest: jest.fn(), - }; - - const simulate = (requestMock: any) => { - return routeHandler( - {}, - { - // stub default values - params: {}, - query: {}, - body: null, - ...requestMock, - }, - responseMock - ); - }; - - return { simulate, handlerMock, createRouter, get, post, responseMock }; - }; - - it('adds a _debug query parameter by default', async () => { - const { simulate, handlerMock, responseMock } = initApi(); - - await simulate({ query: { _debug: 'true' } }); + describe('_inspect', () => { + it('allows _inspect=true', async () => { + const { simulateRequest, handlerMock, responseMock } = initApi(); + await simulateRequest({ query: { _inspect: 'true' } }); + + const params = handlerMock.mock.calls[0][0].context.params; + expect(params).toEqual({ query: { _inspect: true } }); + expect(handlerMock).toHaveBeenCalledTimes(1); + + // responds with ok + expect(responseMock.custom).not.toHaveBeenCalled(); + expect(responseMock.ok).toHaveBeenCalledWith({ + body: { _inspect: [] }, + }); + }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + it('rejects _inspect=1', async () => { + const { simulateRequest, responseMock } = initApi(); + await simulateRequest({ query: { _inspect: 1 } }); + + // responds with error handler + expect(responseMock.ok).not.toHaveBeenCalled(); + expect(responseMock.custom).toHaveBeenCalledWith({ + body: { + attributes: { _inspect: [] }, + message: + 'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)', + }, + statusCode: 400, + }); + }); - expect(handlerMock).toHaveBeenCalledTimes(1); + it('allows omitting _inspect', async () => { + const { simulateRequest, handlerMock, responseMock } = initApi(); + await simulateRequest({ query: {} }); - expect(responseMock.ok).toHaveBeenCalled(); + const params = handlerMock.mock.calls[0][0].context.params; + expect(params).toEqual({ query: { _inspect: false } }); + expect(handlerMock).toHaveBeenCalledTimes(1); - const params = handlerMock.mock.calls[0][0].context.params; - - expect(params).toEqual({ - query: { - _debug: true, - }, + // responds with ok + expect(responseMock.custom).not.toHaveBeenCalled(); + expect(responseMock.ok).toHaveBeenCalledWith({ body: {} }); }); - - await simulate({ - query: { - _debug: 1, - }, - }); - - expect(responseMock.badRequest).toHaveBeenCalled(); }); - it('throws if any parameters are used but no types are defined', async () => { - const { simulate, responseMock } = initApi(); + it('throws if unknown parameters are provided', async () => { + const { simulateRequest, responseMock } = initApi(); - await simulate({ - query: { - _debug: true, - extra: '', - }, + await simulateRequest({ + query: { _inspect: true, extra: '' }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); - await simulate({ + await simulateRequest({ body: { foo: 'bar' }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(2); + expect(responseMock.custom).toHaveBeenCalledTimes(2); - await simulate({ + await simulateRequest({ params: { foo: 'bar', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(3); + expect(responseMock.custom).toHaveBeenCalledTimes(3); }); it('validates path parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ path: t.type({ foo: t.string, @@ -225,7 +244,7 @@ describe('createApi', () => { }) ); - await simulate({ + await simulateRequest({ params: { foo: 'bar', }, @@ -234,7 +253,7 @@ describe('createApi', () => { expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); const params = handlerMock.mock.calls[0][0].context.params; @@ -243,48 +262,48 @@ describe('createApi', () => { foo: 'bar', }, query: { - _debug: false, + _inspect: false, }, }); - await simulate({ + await simulateRequest({ params: { bar: 'foo', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); - await simulate({ + await simulateRequest({ params: { foo: 9, }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(2); + expect(responseMock.custom).toHaveBeenCalledTimes(2); - await simulate({ + await simulateRequest({ params: { foo: 'bar', extra: '', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(3); + expect(responseMock.custom).toHaveBeenCalledTimes(3); }); it('validates body parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ body: t.string, }) ); - await simulate({ + await simulateRequest({ body: '', }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); @@ -293,19 +312,19 @@ describe('createApi', () => { expect(params).toEqual({ body: '', query: { - _debug: false, + _inspect: false, }, }); - await simulate({ + await simulateRequest({ body: null, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); }); it('validates query parameters', async () => { - const { simulate, handlerMock, responseMock } = initApi( + const { simulateRequest, handlerMock, responseMock } = initApi( t.type({ query: t.type({ bar: t.string, @@ -314,15 +333,15 @@ describe('createApi', () => { }) ); - await simulate({ + await simulateRequest({ query: { bar: '', - _debug: 'true', + _inspect: 'true', filterNames: JSON.stringify(['hostName', 'agentName']), }, }); - expect(responseMock.badRequest).not.toHaveBeenCalled(); + expect(responseMock.custom).not.toHaveBeenCalled(); expect(handlerMock).toHaveBeenCalledTimes(1); expect(responseMock.ok).toHaveBeenCalledTimes(1); @@ -331,19 +350,19 @@ describe('createApi', () => { expect(params).toEqual({ query: { bar: '', - _debug: true, + _inspect: true, filterNames: ['hostName', 'agentName'], }, }); - await simulate({ + await simulateRequest({ query: { bar: '', foo: '', }, }); - expect(responseMock.badRequest).toHaveBeenCalledTimes(1); + expect(responseMock.custom).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 46f2628cc73d5..13e70a2043cf0 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -11,19 +11,20 @@ import { schema } from '@kbn/config-schema'; import * as t from 'io-ts'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaResponseFactory, RouteRegistrar } from 'src/core/server'; +import { KibanaRequest, RouteRegistrar } from 'src/core/server'; import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; import agent from 'elastic-apm-node'; +import { parseMethod } from '../../../common/apm_api/parse_endpoint'; import { merge } from '../../../common/runtime_types/merge'; import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; import { APMConfig } from '../..'; -import { ServerAPI } from '../typings'; +import { InspectResponse, RouteParamsRT, ServerAPI } from '../typings'; import { jsonRt } from '../../../common/runtime_types/json_rt'; import type { ApmPluginRequestHandlerContext } from '../typings'; -const debugRt = t.exact( +const inspectRt = t.exact( t.partial({ - query: t.exact(t.partial({ _debug: jsonRt.pipe(t.boolean) })), + query: t.exact(t.partial({ _inspect: jsonRt.pipe(t.boolean) })), }) ); @@ -32,6 +33,11 @@ type RouteOrRouteFactoryFn = Parameters['add']>[0]; const isNotEmpty = (val: any) => val !== undefined && val !== null && !(isPlainObject(val) && isEmpty(val)); +export const inspectableEsQueriesMap = new WeakMap< + KibanaRequest, + InspectResponse +>(); + export function createApi() { const routes: RouteOrRouteFactoryFn[] = []; const api: ServerAPI<{}> = { @@ -58,24 +64,10 @@ export function createApi() { const { params, endpoint, options, handler } = route; const [method, path] = endpoint.split(' '); - - const typedRouterMethod = method.trim().toLowerCase() as - | 'get' - | 'post' - | 'put' - | 'delete'; - - if (!['get', 'post', 'put', 'delete'].includes(typedRouterMethod)) { - throw new Error( - "Couldn't register route, as endpoint was not prefixed with a valid HTTP method" - ); - } + const typedRouterMethod = parseMethod(method); // For all runtime types with props, we create an exact // version that will strip all keys that are unvalidated. - - const paramsRt = params ? merge([params, debugRt]) : debugRt; - const anyObject = schema.object({}, { unknowns: 'allow' }); (router[typedRouterMethod] as RouteRegistrar< @@ -102,56 +94,52 @@ export function createApi() { }); } - try { - const paramMap = pickBy( - { - path: request.params, - body: request.body, - query: { - _debug: 'false', - ...request.query, - }, - }, - isNotEmpty - ); - - const result = strictKeysRt(paramsRt).decode(paramMap); + // init debug queries + inspectableEsQueriesMap.set(request, []); - if (isLeft(result)) { - throw Boom.badRequest(PathReporter.report(result)[0]); - } + try { + const validParams = validateParams(request, params); const data = await handler({ request, context: { ...context, plugins, - // Only return values for parameters that have runtime types, - // but always include query as _debug is always set even if - // it's not defined in the route. - params: mergeLodash( - { query: { _debug: false } }, - pickBy(result.right, isNotEmpty) - ), + params: validParams, config, logger, }, }); - return response.ok({ body: data as any }); + const body = { ...data }; + if (validParams.query._inspect) { + body._inspect = inspectableEsQueriesMap.get(request); + } + + // cleanup + inspectableEsQueriesMap.delete(request); + + return response.ok({ body }); } catch (error) { + const opts = { + statusCode: 500, + body: { + message: error.message, + attributes: { + _inspect: inspectableEsQueriesMap.get(request), + }, + }, + }; + if (Boom.isBoom(error)) { - return convertBoomToKibanaResponse(error, response); + opts.statusCode = error.output.statusCode; } if (error instanceof RequestAbortedError) { - return response.custom({ - statusCode: 499, - body: { - message: 'Client closed request', - }, - }); + opts.statusCode = 499; + opts.body.message = 'Client closed request'; } - throw error; + + return response.custom(opts); } } ); @@ -162,22 +150,35 @@ export function createApi() { return api; } -function convertBoomToKibanaResponse( - error: Boom.Boom, - response: KibanaResponseFactory +function validateParams( + request: KibanaRequest, + params: RouteParamsRT | undefined ) { - const opts = { body: { message: error.message } }; - switch (error.output.statusCode) { - case 404: - return response.notFound(opts); - - case 400: - return response.badRequest(opts); + const paramsRt = params ? merge([params, inspectRt]) : inspectRt; + const paramMap = pickBy( + { + path: request.params, + body: request.body, + query: { + _inspect: 'false', + // @ts-ignore + ...request.query, + }, + }, + isNotEmpty + ); - case 403: - return response.forbidden(opts); + const result = strictKeysRt(paramsRt).decode(paramMap); - default: - throw error; + if (isLeft(result)) { + throw Boom.badRequest(PathReporter.report(result)[0]); } + + // Only return values for parameters that have runtime types, + // but always include query as _inspect is always set even if + // it's not defined in the route. + return mergeLodash( + { query: { _inspect: false } }, + pickBy(result.right, isNotEmpty) + ); } diff --git a/x-pack/plugins/apm/server/routes/create_route.ts b/x-pack/plugins/apm/server/routes/create_route.ts index 4d30e706cdd5c..d74aac0992eb4 100644 --- a/x-pack/plugins/apm/server/routes/create_route.ts +++ b/x-pack/plugins/apm/server/routes/create_route.ts @@ -6,20 +6,20 @@ */ import { CoreSetup } from 'src/core/server'; -import { Route, RouteParamsRT } from './typings'; +import { HandlerReturn, Route, RouteParamsRT } from './typings'; export function createRoute< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: Route ): Route; export function createRoute< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: (core: CoreSetup) => Route ): (core: CoreSetup) => Route; diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts index 448591f7e143f..4aa7d7e6d412f 100644 --- a/x-pack/plugins/apm/server/routes/environments.ts +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -30,10 +30,12 @@ export const environmentsRoute = createRoute({ setup ); - return getEnvironments({ + const environments = await getEnvironments({ setup, serviceName, searchAggregatedTransactions, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 710e614165aa5..f69d3fc9631d1 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -36,7 +36,7 @@ export const errorsRoute = createRoute({ const { serviceName } = params.path; const { environment, kuery, sortField, sortDirection } = params.query; - return getErrorGroups({ + const errorGroups = await getErrorGroups({ environment, kuery, serviceName, @@ -44,6 +44,8 @@ export const errorsRoute = createRoute({ sortDirection, setup, }); + + return { errorGroups }; }, }); diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index ed1354a219164..fd7d2120ab6f5 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -21,10 +21,13 @@ export const staticIndexPatternRoute = createRoute((core) => ({ getInternalSavedObjectsClient(core), ]); - await createStaticIndexPattern(setup, context, savedObjectsClient); + const didCreateIndexPattern = await createStaticIndexPattern( + setup, + context, + savedObjectsClient + ); - // send empty response regardless of outcome - return undefined; + return { created: didCreateIndexPattern }; }, })); @@ -41,6 +44,8 @@ export const apmIndexPatternTitleRoute = createRoute({ endpoint: 'GET /api/apm/index_pattern/title', options: { tags: ['access:apm'] }, handler: async ({ context }) => { - return getApmIndexPatternTitle(context); + return { + indexPatternTitle: getApmIndexPatternTitle(context), + }; }, }); diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 1a1fa799639bc..b9c0a76b6fb90 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceCount } from '../lib/observability_overview/get_service_count'; import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; -import { hasData } from '../lib/observability_overview/has_data'; +import { getHasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; @@ -20,7 +20,8 @@ export const observabilityOverviewHasDataRoute = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return await hasData({ setup }); + const res = await getHasData({ setup }); + return { hasData: res }; }, }); diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index ecf56e2aec246..3156acb469a72 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -79,12 +79,14 @@ export const rumPageLoadDistributionRoute = createRoute({ query: { minPercentile, maxPercentile, urlQuery }, } = context.params; - return getPageLoadDistribution({ + const pageLoadDistribution = await getPageLoadDistribution({ setup, minPercentile, maxPercentile, urlQuery, }); + + return { pageLoadDistribution }; }, }); @@ -105,13 +107,15 @@ export const rumPageLoadDistBreakdownRoute = createRoute({ query: { minPercentile, maxPercentile, breakdown, urlQuery }, } = context.params; - return getPageLoadDistBreakdown({ + const pageLoadDistBreakdown = await getPageLoadDistBreakdown({ setup, minPercentile: Number(minPercentile), maxPercentile: Number(maxPercentile), breakdown, urlQuery, }); + + return { pageLoadDistBreakdown }; }, }); @@ -145,7 +149,8 @@ export const rumServicesRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getRumServices({ setup }); + const rumServices = await getRumServices({ setup }); + return { rumServices }; }, }); @@ -322,12 +327,14 @@ function createLocalFiltersRoute< setup, }); - return getLocalUIFilters({ + const localUiFilters = await getLocalUIFilters({ projection, setup, uiFilters, localFilterNames: filterNames, }); + + return { localUiFilters }; }, }); } diff --git a/x-pack/plugins/apm/server/routes/service_nodes.ts b/x-pack/plugins/apm/server/routes/service_nodes.ts index e65b0b679da5a..e9060688c63a6 100644 --- a/x-pack/plugins/apm/server/routes/service_nodes.ts +++ b/x-pack/plugins/apm/server/routes/service_nodes.ts @@ -26,10 +26,7 @@ export const serviceNodesRoute = createRoute({ const { serviceName } = params.path; const { kuery } = params.query; - return getServiceNodes({ - kuery, - setup, - serviceName, - }); + const serviceNodes = await getServiceNodes({ kuery, setup, serviceName }); + return { serviceNodes }; }, }); diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 7ba19035a90b0..b4d25ca8b2a06 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -56,15 +56,13 @@ export const servicesRoute = createRoute({ setup ); - const services = await getServices({ + return getServices({ environment, kuery, setup, searchAggregatedTransactions, logger: context.logger, }); - - return services; }, }); @@ -465,7 +463,7 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ const { start, end } = setup; - return getServiceInstancesPrimaryStatistics({ + const serviceInstances = await getServiceInstancesPrimaryStatistics({ environment, kuery, latencyAggregationType, @@ -476,6 +474,8 @@ export const serviceInstancesPrimaryStatisticsRoute = createRoute({ start, end, }); + + return { serviceInstances }; }, }); @@ -558,12 +558,14 @@ export const serviceDependenciesRoute = createRoute({ const { serviceName } = context.params.path; const { environment, numBuckets } = context.params.query; - return getServiceDependencies({ + const serviceDependencies = await getServiceDependencies({ serviceName, environment, setup, numBuckets, }); + + return { serviceDependencies }; }, }); @@ -586,12 +588,14 @@ export const serviceProfilingTimelineRoute = createRoute({ query: { environment, kuery }, } = context.params; - return getServiceProfilingTimeline({ + const profilingTimeline = await getServiceProfilingTimeline({ kuery, setup, serviceName, environment, }); + + return { profilingTimeline }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index e3ed398171d01..31e8d6cc1e9f0 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -31,7 +31,8 @@ export const agentConfigurationRoute = createRoute({ options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return await listConfigurations({ setup }); + const configurations = await listConfigurations({ setup }); + return { configurations }; }, }); @@ -204,10 +205,12 @@ export const listAgentConfigurationServicesRoute = createRoute({ const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return await getServiceNames({ + const serviceNames = await getServiceNames({ setup, searchAggregatedTransactions, }); + + return { serviceNames }; }, }); @@ -225,11 +228,13 @@ export const listAgentConfigurationEnvironmentsRoute = createRoute({ setup ); - return await getEnvironments({ + const environments = await getEnvironments({ serviceName, setup, searchAggregatedTransactions, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index e5922d9ed3e94..de7f35c4081bc 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -71,6 +71,8 @@ export const createAnomalyDetectionJobsRoute = createRoute({ licensingPlugin: context.licensing, featureName: 'ml', }); + + return { jobCreated: true }; }, }); @@ -85,10 +87,12 @@ export const anomalyDetectionEnvironmentsRoute = createRoute({ setup ); - return await getAllEnvironments({ + const environments = await getAllEnvironments({ setup, searchAggregatedTransactions, includeMissing: true, }); + + return { environments }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts index 0d47579f50aec..91057c97579e4 100644 --- a/x-pack/plugins/apm/server/routes/settings/apm_indices.ts +++ b/x-pack/plugins/apm/server/routes/settings/apm_indices.ts @@ -18,7 +18,8 @@ export const apmIndexSettingsRoute = createRoute({ endpoint: 'GET /api/apm/settings/apm-index-settings', options: { tags: ['access:apm'] }, handler: async ({ context }) => { - return await getApmIndexSettings({ context }); + const apmIndexSettings = await getApmIndexSettings({ context }); + return { apmIndexSettings }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index fc217bef772d0..a6ab553f09419 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -52,7 +52,8 @@ export const listCustomLinksRoute = createRoute({ const { query } = context.params; // picks only the items listed in FILTER_OPTIONS const filters = pick(query, FILTER_OPTIONS); - return await listCustomLinks({ setup, filters }); + const customLinks = await listCustomLinks({ setup, filters }); + return { customLinks }; }, }); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 4d3e07040f76b..1575041fb2f45 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -13,7 +13,7 @@ import { Logger, } from 'src/core/server'; import { Observable } from 'rxjs'; -import { RequiredKeys } from 'utility-types'; +import { RequiredKeys, DeepPartial } from 'utility-types'; import { ObservabilityPluginSetup } from '../../../observability/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; import { SecurityPluginSetup } from '../../../security/server'; @@ -21,6 +21,20 @@ import { MlPluginSetup } from '../../../ml/server'; import { FetchOptions } from '../../common/fetch_options'; import { APMConfig } from '..'; +export type HandlerReturn = Record; + +interface InspectQueryParam { + query: { _inspect: boolean }; +} + +export type InspectResponse = Array<{ + response: any; + duration: number; + requestType: string; + requestParams: Record; + esError: Error; +}>; + export interface RouteParams { path?: Record; query?: Record; @@ -36,15 +50,14 @@ export type RouteParamsRT = WithoutIncompatibleMethods>; export type RouteHandler< TParamsRT extends RouteParamsRT | undefined, - TReturn + TReturn extends HandlerReturn > = (kibanaContext: { context: APMRequestHandlerContext< - (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & { - query: { _debug: boolean }; - } + (TParamsRT extends RouteParamsRT ? t.TypeOf : {}) & + InspectQueryParam >; request: KibanaRequest; -}) => Promise; +}) => Promise; interface RouteOptions { tags: Array< @@ -58,7 +71,7 @@ interface RouteOptions { export interface Route< TEndpoint extends string, TRouteParamsRT extends RouteParamsRT | undefined, - TReturn + TReturn extends HandlerReturn > { endpoint: TEndpoint; options: RouteOptions; @@ -76,7 +89,7 @@ export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { export type APMRequestHandlerContext< TRouteParams = {} > = ApmPluginRequestHandlerContext & { - params: TRouteParams & { query: { _debug: boolean } }; + params: TRouteParams & InspectQueryParam; config: APMConfig; logger: Logger; plugins: { @@ -97,8 +110,8 @@ export interface ServerAPI { _S: TRouteState; add< TEndpoint extends string, - TRouteParamsRT extends RouteParamsRT | undefined = undefined, - TReturn = unknown + TReturn extends HandlerReturn, + TRouteParamsRT extends RouteParamsRT | undefined = undefined >( route: | Route @@ -108,7 +121,7 @@ export interface ServerAPI { { [key in TEndpoint]: { params: TRouteParamsRT; - ret: TReturn; + ret: TReturn & { _inspect?: InspectResponse }; }; } >; @@ -132,6 +145,16 @@ type MaybeOptional }> = RequiredKeys< ? { params?: T['params'] } : { params: T['params'] }; +export type MaybeParams< + TRouteState, + TEndpoint extends keyof TRouteState & string +> = TRouteState[TEndpoint] extends { params: t.Any } + ? MaybeOptional<{ + params: t.OutputOf & + DeepPartial; + }> + : {}; + export type Client< TRouteState, TOptions extends { abortable: boolean } = { abortable: true } @@ -142,9 +165,7 @@ export type Client< > & { forceCache?: boolean; endpoint: TEndpoint; - } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.OutputOf }> - : {}) & + } & MaybeParams & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< TRouteState[TEndpoint] extends { ret: any } diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 056135b34cf9f..439cae4f414f7 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,8 +17,6 @@ import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; -import { setAutocompleteService } from './services'; -import { setupKqlQuerySuggestionProvider, KUERY_LANGUAGE_NAME } from './autocomplete'; import { EnhancedSearchInterceptor } from './search/search_interceptor'; import { registerSearchSessionsMgmt } from './search/sessions_mgmt'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; @@ -52,11 +50,6 @@ export class DataEnhancedPlugin core: CoreSetup, { bfetch, data, management }: DataEnhancedSetupDependencies ) { - data.autocomplete.addQuerySuggestionProvider( - KUERY_LANGUAGE_NAME, - setupKqlQuerySuggestionProvider(core) - ); - this.enhancedSearchInterceptor = new EnhancedSearchInterceptor({ bfetch, toasts: core.notifications.toasts, @@ -83,8 +76,6 @@ export class DataEnhancedPlugin } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { - setAutocompleteService(plugins.data.autocomplete); - if (this.config.search.sessions.enabled) { core.chrome.setBreadcrumbsAppendExtension({ content: toMountPoint( diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 43d4367f85940..630aea417c84e 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -173,7 +173,12 @@ export const createConnectedSearchSessionIndicator = ({ } }, [state]); - const searchSessionName = useObservable(sessionService.searchSessionName$); + const { + name: searchSessionName, + startTime, + completedTime, + canceledTime, + } = useObservable(sessionService.sessionMeta$, { state }); const saveSearchSessionNameFn = useCallback(async (newName: string) => { await sessionService.renameCurrentSession(newName); }, []); @@ -196,6 +201,9 @@ export const createConnectedSearchSessionIndicator = ({ viewSearchSessionsLink={searchSessionsManagementUrl} searchSessionName={searchSessionName} saveSearchSessionNameFn={saveSearchSessionNameFn} + startedTime={startTime} + completedTime={completedTime} + canceledTime={canceledTime} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx index 01b6c89a0ddc7..e9577c3464d11 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx @@ -24,16 +24,21 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => { return ( <>
- +
- +
@@ -41,6 +46,8 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => { state={SearchSessionState.BackgroundCompleted} searchSessionName={searchSessionName} saveSearchSessionNameFn={saveSearchSessionNameFn} + startedTime={new Date()} + completedTime={new Date()} />
@@ -48,15 +55,23 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => { state={SearchSessionState.Restored} searchSessionName={searchSessionName} saveSearchSessionNameFn={saveSearchSessionNameFn} + startedTime={new Date()} + completedTime={new Date()} />
- +
Promise; + + startedTime?: Date; + completedTime?: Date; + canceledTime?: Date; } type ActionButtonProps = SearchSessionIndicatorProps & { buttonProps: EuiButtonEmptyProps }; @@ -134,6 +139,7 @@ const searchSessionIndicatorViewStateToProps: { popover: { title: string; description: string; + whenText: (props: SearchSessionIndicatorProps) => string; primaryAction?: React.ComponentType; secondaryAction?: React.ComponentType; }; @@ -158,8 +164,15 @@ const searchSessionIndicatorViewStateToProps: { defaultMessage: 'Your search is taking a while...', }), description: i18n.translate('xpack.data.searchSessionIndicator.loadingResultsDescription', { - defaultMessage: 'Save your session, continue your work, and return to completed results.', + defaultMessage: 'Save your session, continue your work, and return to completed results', }), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.loadingResultsWhenText', { + defaultMessage: 'Started {when}', + values: { + when: props.startedTime ? moment(props.startedTime).format(`L @ LTS`) : '', + }, + }), primaryAction: CancelButton, secondaryAction: ContinueInBackgroundButton, }, @@ -185,9 +198,16 @@ const searchSessionIndicatorViewStateToProps: { description: i18n.translate( 'xpack.data.searchSessionIndicator.resultsLoadedDescriptionText', { - defaultMessage: 'Save your session and return to it later.', + defaultMessage: 'Save your session and return to it later', } ), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.resultsLoadedWhenText', { + defaultMessage: 'Completed {when}', + values: { + when: props.completedTime ? moment(props.completedTime).format(`L @ LTS`) : '', + }, + }), primaryAction: SaveButton, secondaryAction: ViewAllSearchSessionsButton, }, @@ -215,9 +235,16 @@ const searchSessionIndicatorViewStateToProps: { description: i18n.translate( 'xpack.data.searchSessionIndicator.loadingInTheBackgroundDescriptionText', { - defaultMessage: 'You can return to completed results from Management.', + defaultMessage: 'You can return to completed results from Management', } ), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.loadingInTheBackgroundWhenText', { + defaultMessage: 'Started {when}', + values: { + when: props.startedTime ? moment(props.startedTime).format(`L @ LTS`) : '', + }, + }), primaryAction: CancelButton, secondaryAction: ViewAllSearchSessionsButton, }, @@ -249,9 +276,16 @@ const searchSessionIndicatorViewStateToProps: { description: i18n.translate( 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundDescriptionText', { - defaultMessage: 'You can return to these results from Management.', + defaultMessage: 'You can return to these results from Management', } ), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundWhenText', { + defaultMessage: 'Completed {when}', + values: { + when: props.completedTime ? moment(props.completedTime).format(`L @ LTS`) : '', + }, + }), secondaryAction: ViewAllSearchSessionsButton, }, }, @@ -275,8 +309,15 @@ const searchSessionIndicatorViewStateToProps: { }), description: i18n.translate('xpack.data.searchSessionIndicator.restoredDescriptionText', { defaultMessage: - 'You are viewing cached data from a specific time range. Changing the time range or filters will re-run the session.', + 'You are viewing cached data from a specific time range. Changing the time range or filters will re-run the session', }), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.restoredWhenText', { + defaultMessage: 'Completed {when}', + values: { + when: props.completedTime ? moment(props.completedTime).format(`L @ LTS`) : '', + }, + }), secondaryAction: ViewAllSearchSessionsButton, }, }, @@ -296,8 +337,15 @@ const searchSessionIndicatorViewStateToProps: { defaultMessage: 'Search session stopped', }), description: i18n.translate('xpack.data.searchSessionIndicator.canceledDescriptionText', { - defaultMessage: 'You are viewing incomplete data.', + defaultMessage: 'You are viewing incomplete data', }), + whenText: (props: SearchSessionIndicatorProps) => + i18n.translate('xpack.data.searchSessionIndicator.canceledWhenText', { + defaultMessage: 'Stopped {when}', + values: { + when: props.canceledTime ? moment(props.canceledTime).format(`L @ LTS`) : '', + }, + }), secondaryAction: ViewAllSearchSessionsButton, }, }, @@ -370,22 +418,30 @@ export const SearchSessionIndicator = React.forwardRef< >
{props.searchSessionName && props.saveSearchSessionNameFn ? ( + + ) : ( + +

{popover.title}

+
+ )} + + + {popover.whenText?.(props) ? ( <> - + +

{popover.whenText(props)}

+
) : null} - -

{popover.title}

-
- +

{popover.description}

- + ('Autocomplete'); diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 1037de4f79ea7..462d1fc337ae2 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -15,7 +15,7 @@ import { import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { ENHANCED_ES_SEARCH_STRATEGY, EQL_SEARCH_STRATEGY } from '../common'; import { registerSessionRoutes } from './routes'; -import { searchSessionMapping } from './saved_objects'; +import { searchSessionSavedObjectType } from './saved_objects'; import { SearchSessionService, enhancedEsSearchStrategyProvider, @@ -54,7 +54,7 @@ export class EnhancedDataServerPlugin const usage = deps.usageCollection ? usageProvider(core) : undefined; core.uiSettings.register(getUiSettings()); - core.savedObjects.registerType(searchSessionMapping); + core.savedObjects.registerType(searchSessionSavedObjectType); deps.data.search.registerSearchStrategy( ENHANCED_ES_SEARCH_STRATEGY, diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts index fd3d24b71f97d..4e53e951a8dc3 100644 --- a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts @@ -7,8 +7,9 @@ import { SavedObjectsType } from 'kibana/server'; import { SEARCH_SESSION_TYPE } from '../../common'; +import { searchSessionSavedObjectMigrations } from './search_session_migration'; -export const searchSessionMapping: SavedObjectsType = { +export const searchSessionSavedObjectType: SavedObjectsType = { name: SEARCH_SESSION_TYPE, namespaceType: 'single', hidden: true, @@ -32,6 +33,9 @@ export const searchSessionMapping: SavedObjectsType = { touched: { type: 'date', }, + completed: { + type: 'date', + }, status: { type: 'keyword', }, @@ -64,4 +68,5 @@ export const searchSessionMapping: SavedObjectsType = { }, }, }, + migrations: searchSessionSavedObjectMigrations, }; diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.test.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.test.ts new file mode 100644 index 0000000000000..53b1b7f52b363 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + searchSessionSavedObjectMigrations, + SearchSessionSavedObjectAttributesPre$7$13$0, +} from './search_session_migration'; +import { SavedObject } from '../../../../../src/core/types'; +import { SEARCH_SESSION_TYPE } from '../../../../../src/plugins/data/common'; +import { SearchSessionStatus } from '../../common/search/session/status'; +import { SavedObjectMigrationContext } from 'kibana/server'; + +const mockCompletedSessionSavedObject: SavedObject = { + id: 'id', + type: SEARCH_SESSION_TYPE, + attributes: { + name: 'my_name', + appId: 'my_app_id', + sessionId: 'sessionId', + urlGeneratorId: 'my_url_generator_id', + initialState: {}, + restoreState: {}, + persisted: true, + idMapping: {}, + realmType: 'realmType', + realmName: 'realmName', + username: 'username', + created: '2021-03-26T00:00:00.000Z', + expires: '2021-03-30T00:00:00.000Z', + touched: '2021-03-29T00:00:00.000Z', + status: SearchSessionStatus.COMPLETE, + }, + references: [], +}; + +const mockInProgressSessionSavedObject: SavedObject = { + id: 'id', + type: SEARCH_SESSION_TYPE, + attributes: { + name: 'my_name', + appId: 'my_app_id', + sessionId: 'sessionId', + urlGeneratorId: 'my_url_generator_id', + initialState: {}, + restoreState: {}, + persisted: true, + idMapping: {}, + realmType: 'realmType', + realmName: 'realmName', + username: 'username', + created: '2021-03-26T00:00:00.000Z', + expires: '2021-03-30T00:00:00.000Z', + touched: '2021-03-29T00:00:00.000Z', + status: SearchSessionStatus.IN_PROGRESS, + }, + references: [], +}; + +describe('7.12.0 -> 7.13.0', () => { + const migration = searchSessionSavedObjectMigrations['7.13.0']; + test('"completed" is populated from "touched" for completed session', () => { + const migratedCompletedSession = migration( + mockCompletedSessionSavedObject, + {} as SavedObjectMigrationContext + ); + + expect(migratedCompletedSession.attributes).toHaveProperty('completed'); + expect(migratedCompletedSession.attributes.completed).toBe( + migratedCompletedSession.attributes.touched + ); + expect(migratedCompletedSession.attributes).toMatchInlineSnapshot(` + Object { + "appId": "my_app_id", + "completed": "2021-03-29T00:00:00.000Z", + "created": "2021-03-26T00:00:00.000Z", + "expires": "2021-03-30T00:00:00.000Z", + "idMapping": Object {}, + "initialState": Object {}, + "name": "my_name", + "persisted": true, + "realmName": "realmName", + "realmType": "realmType", + "restoreState": Object {}, + "sessionId": "sessionId", + "status": "complete", + "touched": "2021-03-29T00:00:00.000Z", + "urlGeneratorId": "my_url_generator_id", + "username": "username", + } + `); + }); + + test('"completed" is missing for in-progress session', () => { + const migratedInProgressSession = migration( + mockInProgressSessionSavedObject, + {} as SavedObjectMigrationContext + ); + + expect(migratedInProgressSession.attributes).not.toHaveProperty('completed'); + + expect(migratedInProgressSession.attributes).toEqual( + mockInProgressSessionSavedObject.attributes + ); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.ts new file mode 100644 index 0000000000000..b9ea85a333da2 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session_migration.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectMigrationMap, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { + SearchSessionSavedObjectAttributes as SearchSessionSavedObjectAttributesLatest, + SearchSessionStatus, +} from '../../common'; + +/** + * Search sessions were released in 7.12.0 + * In 7.13.0 a `completed` field was added. + * It is a timestamp representing the session was transitioned into "completed" status. + */ +export type SearchSessionSavedObjectAttributesPre$7$13$0 = Omit< + SearchSessionSavedObjectAttributesLatest, + 'completed' +>; + +export const searchSessionSavedObjectMigrations: SavedObjectMigrationMap = { + '7.13.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectUnsanitizedDoc => { + if (doc.attributes.status === SearchSessionStatus.COMPLETE) { + return { + ...doc, + attributes: { + ...doc.attributes, + completed: doc.attributes.touched, + }, + }; + } + + return doc; + }, +}; diff --git a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts index e1b17968af24f..f9c62069154b6 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.test.ts @@ -521,6 +521,7 @@ describe('getSearchStatus', () => { const updatedAttributes = updateInput[0].attributes as SearchSessionSavedObjectAttributes; expect(updatedAttributes.status).toBe(SearchSessionStatus.COMPLETE); expect(updatedAttributes.touched).not.toBe('123'); + expect(updatedAttributes.completed).not.toBeUndefined(); expect(updatedAttributes.idMapping['search-hash'].status).toBe(SearchStatus.COMPLETE); expect(updatedAttributes.idMapping['search-hash'].error).toBeUndefined(); diff --git a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts index ab5d1b2ff9150..e521c39d7cfd3 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/check_running_sessions.ts @@ -6,21 +6,21 @@ */ import { - Logger, ElasticsearchClient, - SavedObjectsFindResult, + Logger, SavedObjectsClientContract, + SavedObjectsFindResult, } from 'kibana/server'; import moment from 'moment'; import { EMPTY, from } from 'rxjs'; import { expand, mergeMap } from 'rxjs/operators'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { - SearchSessionStatus, - SearchSessionSavedObjectAttributes, - SearchSessionRequestInfo, - SEARCH_SESSION_TYPE, ENHANCED_ES_SEARCH_STRATEGY, + SEARCH_SESSION_TYPE, + SearchSessionRequestInfo, + SearchSessionSavedObjectAttributes, + SearchSessionStatus, } from '../../../common'; import { getSearchStatus } from './get_search_status'; import { getSessionStatus } from './get_session_status'; @@ -93,8 +93,14 @@ async function updateSessionStatus( // And only then derive the session's status const sessionStatus = getSessionStatus(session.attributes); if (sessionStatus !== session.attributes.status) { + const now = new Date().toISOString(); session.attributes.status = sessionStatus; - session.attributes.touched = new Date().toISOString(); + session.attributes.touched = now; + if (sessionStatus === SearchSessionStatus.COMPLETE) { + session.attributes.completed = now; + } else if (session.attributes.completed) { + session.attributes.completed = null; + } sessionUpdated = true; } diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 216c115545a45..047b9b06516ba 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -13,8 +13,6 @@ "server/**/*", "config.ts", "../../../typings/**/*", - // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 - "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json", "common/search/test_data/*.json" ], "references": [ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx index a0b0e4402c1e4..624cc57e1eb22 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; -import { runActionColumnTests } from './shared_columns_tests'; +import { runActionColumnTests } from './test_helpers/shared_columns_tests'; import { AnalyticsTable } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx index 0670624492db5..6021363183098 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; -import { runActionColumnTests } from './shared_columns_tests'; +import { runActionColumnTests } from './test_helpers/shared_columns_tests'; import { RecentQueriesTable } from './'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx similarity index 96% rename from x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx index cb78a6585e43c..95af7b52487d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/test_helpers/shared_columns_tests.tsx @@ -9,8 +9,8 @@ import { mockHttpValues, mockKibanaValues, mockFlashMessageHelpers, -} from '../../../../../__mocks__'; -import '../../../../__mocks__/engine_logic.mock'; +} from '../../../../../../__mocks__'; +import '../../../../../__mocks__/engine_logic.mock'; import { ReactWrapper } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx index fc411c3dff866..72cbe5bdd898c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials.tsx @@ -11,7 +11,6 @@ import { useActions, useValues } from 'kea'; import { EuiPageHeader, - EuiPageHeaderSection, EuiTitle, EuiPageContentBody, EuiPanel, @@ -55,13 +54,7 @@ export const Credentials: React.FC = () => { return ( <> - - - -

{CREDENTIALS_TITLE}

-
-
-
+ {shouldShowCredentialsForm && } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index d06144023e170..e680579f7b0b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { shallow, ReactWrapper } from 'enzyme'; -import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiPageHeader, EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui'; import { Loading } from '../../../../shared/loading'; @@ -64,7 +64,7 @@ describe('Curations', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('h1').text()).toEqual('Curated results'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Curated results'); expect(wrapper.find(CurationsTable)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index fd0a36dfebec7..42b030328ce9a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -11,9 +11,7 @@ import { useValues, useActions } from 'kea'; import { EuiPageHeader, - EuiPageHeaderSection, EuiPageContent, - EuiTitle, EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt, @@ -47,18 +45,14 @@ export const Curations: React.FC = () => { return ( <> - - - -

{CURATIONS_OVERVIEW_TITLE}

-
-
- + {CREATE_NEW_CURATION_TITLE} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx index ba060b7497270..a33161918c7f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.test.tsx @@ -14,7 +14,7 @@ import { useParams } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { EuiPageContent, EuiBasicTable } from '@elastic/eui'; +import { EuiPageHeader, EuiPageContent, EuiBasicTable } from '@elastic/eui'; import { Loading } from '../../../shared/loading'; import { ResultFieldValue } from '../result'; @@ -102,7 +102,8 @@ describe('DocumentDetail', () => { it('will delete the document when the delete button is pressed', () => { const wrapper = shallow(); - const button = wrapper.find('[data-test-subj="DeleteDocumentButton"]'); + const header = wrapper.find(EuiPageHeader).dive().children().dive(); + const button = header.find('[data-test-subj="DeleteDocumentButton"]'); button.simulate('click'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx index 8f80978c29002..0ad000d289d2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail.tsx @@ -13,8 +13,6 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, EuiPageContentBody, EuiPageContent, EuiBasicTable, @@ -79,13 +77,9 @@ export const DocumentDetail: React.FC = ({ engineBreadcrumb }) => { return ( <> - - - -

{DOCUMENT_DETAIL_TITLE(documentTitle)}

-
-
- + = ({ engineBreadcrumb }) => { {i18n.translate('xpack.enterpriseSearch.appSearch.documentDetail.deleteButton', { defaultMessage: 'Delete', })} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx index ace76ae55c046..ed4773e257a2b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.test.tsx @@ -9,7 +9,9 @@ import { setMockValues } from '../../../__mocks__/kea.mock'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiPageHeader } from '@elastic/eui'; import { DocumentCreationButton } from './document_creation_button'; import { SearchExperience } from './search_experience'; @@ -32,46 +34,61 @@ describe('Documents', () => { expect(wrapper.find(SearchExperience).exists()).toBe(true); }); - it('renders a DocumentCreationButton if the user can manage engine documents', () => { - setMockValues({ - ...values, - myRole: { canManageEngineDocuments: true }, + describe('DocumentCreationButton', () => { + const getHeader = (wrapper: ShallowWrapper) => + wrapper.find(EuiPageHeader).dive().children().dive(); + + it('renders a DocumentCreationButton if the user can manage engine documents', () => { + setMockValues({ + ...values, + myRole: { canManageEngineDocuments: true }, + }); + + const wrapper = shallow(); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(true); }); - const wrapper = shallow(); - expect(wrapper.find(DocumentCreationButton).exists()).toBe(true); - }); + it('does not render a DocumentCreationButton if the user cannot manage engine documents', () => { + setMockValues({ + ...values, + myRole: { canManageEngineDocuments: false }, + }); - describe('Meta Engines', () => { - it('renders a Meta Engines message if this is a meta engine', () => { + const wrapper = shallow(); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); + }); + + it('does not render a DocumentCreationButton for meta engines even if the user can manage engine documents', () => { setMockValues({ ...values, + myRole: { canManageEngineDocuments: true }, isMetaEngine: true, }); const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); + expect(getHeader(wrapper).find(DocumentCreationButton).exists()).toBe(false); }); + }); - it('does not render a Meta Engines message if this is not a meta engine', () => { + describe('Meta Engines', () => { + it('renders a Meta Engines message if this is a meta engine', () => { setMockValues({ ...values, - isMetaEngine: false, + isMetaEngine: true, }); const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(true); }); - it('does not render a DocumentCreationButton even if the user can manage engine documents', () => { + it('does not render a Meta Engines message if this is not a meta engine', () => { setMockValues({ ...values, - myRole: { canManageEngineDocuments: true }, - isMetaEngine: true, + isMetaEngine: false, }); const wrapper = shallow(); - expect(wrapper.find(DocumentCreationButton).exists()).toBe(false); + expect(wrapper.find('[data-test-subj="MetaEnginesCallout"]').exists()).toBe(false); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx index 8c3ae7fd24f6d..84fcab53e9604 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/documents.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiPageHeader, EuiPageHeaderSection, EuiTitle, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiPageHeader, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -33,18 +33,14 @@ export const Documents: React.FC = ({ engineBreadcrumb }) => { return ( <> - - - -

{DOCUMENTS_TITLE}

-
-
- {myRole.canManageEngineDocuments && !isMetaEngine && ( - - - - )} -
+ ] + : undefined + } + /> {isMetaEngine && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx index 497c00d1f9144..bab31d0fccc40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_creation/engine_creation.tsx @@ -18,9 +18,7 @@ import { EuiSelect, EuiPageBody, EuiPageHeader, - EuiPageHeaderSection, EuiSpacer, - EuiText, EuiTitle, EuiButton, EuiPanel, @@ -49,13 +47,7 @@ export const EngineCreation: React.FC = () => { return (
- - - -

{ENGINE_CREATION_TITLE}

-
-
-
+ @@ -68,7 +60,7 @@ export const EngineCreation: React.FC = () => { }} > - {ENGINE_CREATION_FORM_TITLE} +

{ENGINE_CREATION_FORM_TITLE}

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index 9066283229a04..ea47dc8956ddd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiPageHeader, EuiButton } from '@elastic/eui'; import { docLinks } from '../../../shared/doc_links'; @@ -25,11 +25,12 @@ describe('EmptyEngineOverview', () => { }); it('renders', () => { - expect(wrapper.find('h1').text()).toEqual('Engine setup'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine setup'); }); it('renders a documentation link', () => { - expect(wrapper.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); + const header = wrapper.find(EuiPageHeader).dive().children().dive(); + expect(header.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx index f505f08a3531a..d48664febb5f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.tsx @@ -7,13 +7,7 @@ import React from 'react'; -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiPageContentBody, - EuiTitle, - EuiButton, -} from '@elastic/eui'; +import { EuiPageHeader, EuiPageContentBody, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -23,25 +17,20 @@ import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_cre export const EmptyEngineOverview: React.FC = () => { return ( <> - - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.engine.overview.empty.heading', { - defaultMessage: 'Engine setup', - })} -

-
-
- + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.overview.empty.headingAction', { defaultMessage: 'View documentation' } )} - - -
+ , + ]} + /> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx index 638c8b0da87ce..d51bef3b29761 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.test.tsx @@ -11,13 +11,15 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiPageHeader } from '@elastic/eui'; + import { UnavailablePrompt, TotalStats, TotalCharts, RecentApiLogs } from './components'; import { EngineOverviewMetrics } from './engine_overview_metrics'; describe('EngineOverviewMetrics', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('h1').text()).toEqual('Engine overview'); + expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('Engine overview'); }); it('renders an unavailable prompt if engine data is still indexing', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx index c60cf70f435c5..8d376ff1971a3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_metrics.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPageHeader, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -23,15 +23,11 @@ export const EngineOverviewMetrics: React.FC = () => { return ( <> - - -

- {i18n.translate('xpack.enterpriseSearch.appSearch.engine.overview.heading', { - defaultMessage: 'Engine overview', - })} -

-
-
+ {apiLogsUnavailable ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx index 5ccd2c552ef02..3ffe2f3d43a77 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.test.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/enterprise_search_url.mock'; import { mockTelemetryActions } from '../../../../__mocks__'; @@ -16,13 +15,16 @@ import { shallow } from 'enzyme'; import { EnginesOverviewHeader } from './'; describe('EnginesOverviewHeader', () => { + const wrapper = shallow() + .dive() + .children() + .dive(); + it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find('h1')).toHaveLength(1); + expect(wrapper.find('h1').text()).toEqual('Engines overview'); }); it('renders a launch app search button that sends telemetry on click', () => { - const wrapper = shallow(); const button = wrapper.find('[data-test-subj="launchButton"]'); expect(button.prop('href')).toBe('http://localhost:3002/as'); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx index 290270c08258c..fb3b771850a31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/components/header.tsx @@ -9,15 +9,8 @@ import React from 'react'; import { useActions } from 'kea'; -import { - EuiPageHeader, - EuiPageHeaderSection, - EuiTitle, - EuiButton, - EuiButtonProps, - EuiLinkProps, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageHeader, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { getAppSearchUrl } from '../../../../shared/enterprise_search_url'; import { TelemetryLogic } from '../../../../shared/telemetry'; @@ -25,39 +18,31 @@ import { TelemetryLogic } from '../../../../shared/telemetry'; export const EnginesOverviewHeader: React.FC = () => { const { sendAppSearchTelemetry } = useActions(TelemetryLogic); - const buttonProps = { - fill: true, - iconType: 'popout', - 'data-test-subj': 'launchButton', - href: getAppSearchUrl(), - target: '_blank', - onClick: () => - sendAppSearchTelemetry({ - action: 'clicked', - metric: 'header_launch_button', - }), - } as EuiButtonProps & EuiLinkProps; - return ( - - - -

- -

-
-
- - - - - -
+ + sendAppSearchTelemetry({ + action: 'clicked', + metric: 'header_launch_button', + }) + } + data-test-subj="launchButton" + > + {i18n.translate('xpack.enterpriseSearch.appSearch.productCta', { + defaultMessage: 'Launch App Search', + })} + , + ]} + /> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx index 594584d9ba101..5e268cc0fd214 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/library/library.tsx @@ -10,7 +10,6 @@ import React, { useState } from 'react'; import { EuiSpacer, EuiPageHeader, - EuiPageHeaderSection, EuiTitle, EuiPageContentBody, EuiPageContent, @@ -86,13 +85,7 @@ export const Library: React.FC = () => { return ( <> - - - -

Library

-
-
-
+ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx index d701ee37a1658..a3dbf7259975b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/meta_engine_creation/meta_engine_creation.tsx @@ -20,9 +20,7 @@ import { EuiFieldText, EuiPageContent, EuiPageHeader, - EuiPageHeaderSection, EuiSpacer, - EuiText, EuiTitle, EuiButton, } from '@elastic/eui'; @@ -78,15 +76,16 @@ export const MetaEngineCreation: React.FC = () => { return (
- - - -

{META_ENGINE_CREATION_TITLE}

-
- {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} - {META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION} -
-
+ + {META_ENGINE_CREATION_FORM_META_ENGINE_DESCRIPTION} +
+ {META_ENGINE_CREATION_FORM_DOCUMENTATION_DESCRIPTION} + + } + /> { }} > - {META_ENGINE_CREATION_FORM_TITLE} +

{META_ENGINE_CREATION_FORM_TITLE}

@@ -140,14 +139,16 @@ export const MetaEngineCreation: React.FC = () => { }} /> - {selectedIndexedEngineNames.length > maxEnginesPerMetaEngine && ( - + <> + + + )} { return ( <> - - - -

{SETTINGS_TITLE}

-
-
-
+ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts index 82fc00923202f..34e67acc870ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -82,4 +82,14 @@ describe('AppLogic', () => { expect(AppLogic.values.account.canCreatePersonalSources).toEqual(true); }); }); + + describe('setOrgName', () => { + it('sets property', () => { + const NAME = 'new name'; + mount(DEFAULT_INITIAL_APP_DATA); + AppLogic.actions.setOrgName(NAME); + + expect(AppLogic.values.organization.name).toEqual(NAME); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index b81f538bd4709..26e1d7fbb93fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -22,6 +22,7 @@ interface AppValues extends WorkplaceSearchInitialData { interface AppActions { initializeAppData(props: InitialAppData): InitialAppData; setContext(isOrganization: boolean): boolean; + setOrgName(name: string): string; setSourceRestriction(canCreatePersonalSources: boolean): boolean; } @@ -36,6 +37,7 @@ export const AppLogic = kea>({ isFederatedAuth, }), setContext: (isOrganization) => isOrganization, + setOrgName: (name: string) => name, setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources, }, reducers: { @@ -61,6 +63,10 @@ export const AppLogic = kea>({ emptyOrg, { initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.organization || emptyOrg, + setOrgName: (state, name) => ({ + ...state, + name, + }), }, ], account: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx index a7a788b48789a..2cd47f1c1b597 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx @@ -9,13 +9,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiButtonEmpty } from '@elastic/eui'; - import { externalUrl } from '../../../shared/enterprise_search_url'; +import { WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; import { WorkplaceSearchHeaderActions } from './'; describe('WorkplaceSearchHeaderActions', () => { + const ENT_SEARCH_URL = 'http://localhost:3002'; + it('does not render without an Enterprise Search URL set', () => { const wrapper = shallow(); @@ -23,22 +24,32 @@ describe('WorkplaceSearchHeaderActions', () => { }); it('renders a link to the personal dashboard', () => { - externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; - + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).first().prop('href')).toEqual( - 'http://localhost:3002/ws/sources' + expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]').prop('to')).toEqual( + '/p/sources' ); + expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]')).toHaveLength(0); }); it('renders a link to the search application', () => { - externalUrl.enterpriseSearchUrl = 'http://localhost:3002'; - + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; const wrapper = shallow(); - expect(wrapper.find(EuiButtonEmpty).last().prop('href')).toEqual( + expect(wrapper.find('[data-test-subj="HeaderSearchButton"]').prop('href')).toEqual( 'http://localhost:3002/ws/search' ); }); + + it('renders an MVP link back to the legacy dashboard on the MVP page', () => { + window.history.pushState({}, 'Overview', WORKPLACE_SEARCH_URL_PREFIX); + externalUrl.enterpriseSearchUrl = ENT_SEARCH_URL; + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="PersonalDashboardMVPButton"]').prop('href')).toEqual( + `${ENT_SEARCH_URL}/ws/sources` + ); + expect(wrapper.find('[data-test-subj="PersonalDashboardButton"]')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx index 95d7920ae0435..7d594ce66aea1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx @@ -10,20 +10,46 @@ import React from 'react'; import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { externalUrl, getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; -import { NAV } from '../../constants'; +import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; +import { NAV, WORKPLACE_SEARCH_URL_PREFIX } from '../../constants'; +import { PERSONAL_SOURCES_PATH } from '../../routes'; export const WorkplaceSearchHeaderActions: React.FC = () => { if (!externalUrl.enterpriseSearchUrl) return null; + const isMVP = window.location.pathname.endsWith(WORKPLACE_SEARCH_URL_PREFIX); + + const personalDashboardMVPButton = ( + + {NAV.PERSONAL_DASHBOARD} + + ); + + const personalDashboardButton = ( + + {NAV.PERSONAL_DASHBOARD} + + ); + return ( + {isMVP ? personalDashboardMVPButton : personalDashboardButton} - - {NAV.PERSONAL_DASHBOARD} - - - - + {NAV.SEARCH} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx index d9c2d70e78c08..d37af01287c46 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -47,8 +47,7 @@ describe('ContentSection', () => { /> ); - expect(wrapper.find(EuiSpacer).first().prop('size')).toEqual('s'); - expect(wrapper.find(EuiSpacer)).toHaveLength(2); + expect(wrapper.find(EuiSpacer)).toHaveLength(1); expect(wrapper.find('.header')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx index d9a4ed7eee8b8..f0b86e0cc925b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -30,7 +30,6 @@ export const ContentSection: React.FC = ({ description, action, headerChildren, - headerSpacer, testSubj, }) => (
@@ -38,10 +37,9 @@ export const ContentSection: React.FC = ({ <> {headerChildren} - {headerSpacer && } )} {children} - +
); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 2d9e5580c6f40..338eda0214ea2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -18,7 +18,7 @@ import { IconType, } from '@elastic/eui'; -import { EuiButtonTo, EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; +import { EuiButtonTo } from '../../../shared/react_router_helpers'; import { TelemetryLogic } from '../../../shared/telemetry'; interface OnboardingCardProps { @@ -49,15 +49,15 @@ export const OnboardingCard: React.FC = ({ }); const completeButton = actionPath ? ( - + {actionTitle} - + ) : ( {actionTitle} ); const incompleteButton = actionPath ? ( - + {actionTitle} ) : ( @@ -66,7 +66,7 @@ export const OnboardingCard: React.FC = ({ return ( - + { }); return ( - + - + @@ -158,16 +158,17 @@ export const OrgNameOnboarding: React.FC = () => { - - +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx index 525035030b8cc..d1f0f6a030421 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/organization_stats.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiFlexGrid } from '@elastic/eui'; +import { EuiFlexGrid, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -35,45 +35,46 @@ export const OrganizationStats: React.FC = () => { defaultMessage="Usage statistics" /> } - headerSpacer="m" > - - + + + {!isFederatedAuth && ( + <> + + + )} - count={sourcesCount} - actionPath={SOURCES_PATH} - /> - {!isFederatedAuth && ( - <> - - - - )} - - + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 62b96442b9ba0..8bda7c2843b9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -41,7 +41,7 @@ export const RecentActivity: React.FC = () => { return ( - + {activityFeed.length > 0 ? ( <> {activityFeed.map((props: FeedActivity, index) => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx index 136901f840b89..9b134b511b34e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/statistic_card.tsx @@ -24,6 +24,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, layout="horizontal" title={title} titleSize="xs" + display="plain" description={ {count} @@ -36,6 +37,7 @@ export const StatisticCard: React.FC = ({ title, count = 0, layout="horizontal" title={title} titleSize="xs" + display="plain" description={ {count} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts index ad552ff8f5a41..e07adbde15939 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -17,6 +17,7 @@ import { } from '../../../shared/flash_messages'; import { HttpLogic } from '../../../shared/http'; import { KibanaLogic } from '../../../shared/kibana'; +import { AppLogic } from '../../app_logic'; import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; import { ORG_SETTINGS_CONNECTORS_PATH } from '../../routes'; import { Connector } from '../../types'; @@ -150,6 +151,7 @@ export const SettingsLogic = kea> const response = await http.put(route, { body }); actions.setUpdatedName(response); setSuccessMessage(ORG_UPDATED_MESSAGE); + AppLogic.actions.setOrgName(name); } catch (e) { flashAPIErrors(e); } diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index 2656718cc15ea..2272341c65f5e 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -6,13 +6,23 @@ actitivies. ## Overview This plugin provides a persistent log of "events" that can be used by other -plugins to record their processing, for later acccess. Currently it's only -used by the alerts and actions plugins. +plugins to record their processing, for later accces. It is used by: -The "events" are ECS documents, with some custom properties for Kibana, and -alerting-specific properties within those Kibana properties. The number of -ECS fields is limited today, but can be extended fairly easily. We are being -conservative in adding new fields though, to help prevent indexing explosions. +- `alerting` and `actions` plugins +- [work in progress] `security_solution` (detection rules execution log) + +The "events" are [ECS documents](https://www.elastic.co/guide/en/ecs/current/index.html) +containing both standard ECS fields and some custom fields for Kibana. + +- Standard fields are those which are defined in the ECS specification. + Examples: `@timestamp`, `message`, `event.provider`. The number of ECS fields + supported in Event Log is limited today, but can be extended fairly easily. + We are being conservative in adding new fields though, to help prevent + indexing explosions. +- Custom fields are not part of the ECS spec. We defined a top-level `kibana` + field set where we have some Kibana-specific fields like `kibana.server_uuid` + and `kibana.saved_objects`. Plugins added a few custom fields as well, + for example `kibana.alerting` field set. A client API is available for other plugins to: @@ -47,16 +57,25 @@ The structure of the event documents can be seen in the generated via a script when the structure changes. See the [README.md](generated/README.md) for how to change the document structure. -Below is an document in the expected structure, with descriptions of the fields: +Below is a document in the expected structure, with descriptions of the fields: ```js { + // Base ECS fields. + // https://www.elastic.co/guide/en/ecs/current/ecs-base.html "@timestamp": "ISO date", tags: ["tags", "here"], message: "message for humans here", + + // ECS version. This is set by the Event Log and should not be specified + // by a client of Event Log. + // https://www.elastic.co/guide/en/ecs/current/ecs-ecs.html ecs: { version: "version of ECS used by the event log", }, + + // Event fields. All of them are supported. + // https://www.elastic.co/guide/en/ecs/current/ecs-event.html event: { provider: "see below", action: "see below", @@ -65,19 +84,44 @@ Below is an document in the expected structure, with descriptions of the fields: end: "ISO date of end time for events that capture a duration", outcome: "success | failure, for events that indicate an outcome", reason: "additional detail on failure outcome", + // etc }, + + // Error fields. All of them are supported. + // https://www.elastic.co/guide/en/ecs/current/ecs-error.html error: { message: "an error message, usually associated with outcome: failure", + // etc + }, + + // Log fields. Only a subset is supported. + // https://www.elastic.co/guide/en/ecs/current/ecs-log.html + log: { + level: "info | warning | any log level keyword you need", + logger: "name of the logger", + }, + + // Rule fields. All of them are supported. + // https://www.elastic.co/guide/en/ecs/current/ecs-rule.html + rule: { + author: ["Elastic"], + id: "a823fd56-5467-4727-acb1-66809737d943", + // etc }, + + // User fields. Only user.name is supported. + // https://www.elastic.co/guide/en/ecs/current/ecs-user.html user: { name: "name of Kibana user", }, - kibana: { // custom ECS field + + // Custom fields that are not part of ECS. + kibana: { server_uuid: "UUID of kibana server, for diagnosing multi-Kibana scenarios", alerting: { instance_id: "alert instance id, for relevant documents", action_group_id: "alert action group, for relevant documents", - action_subgroup_id: "alert action subgroup, for relevant documents", + action_subgroup: "alert action subgroup, for relevant documents", status: "overall alert status, after alert execution", }, saved_objects: [ @@ -363,3 +407,14 @@ yarn test:jest x-pack/plugins/event_log --watch See: [`x-pack/test/plugin_api_integration/test_suites/event_log`](https://github.com/elastic/kibana/tree/master/x-pack/test/plugin_api_integration/test_suites/event_log). +To develop integration tests, first start the test server from the root of the repo: + +```sh +node scripts/functional_tests_server --config x-pack/test/plugin_api_integration/config.ts +``` + +Then start the test runner: + +```sh +node scripts/functional_test_runner --config x-pack/test/plugin_api_integration/config.ts --include x-pack/test/plugin_api_integration/test_suites/event_log/index.ts +``` diff --git a/x-pack/plugins/event_log/generated/README.md b/x-pack/plugins/event_log/generated/README.md index 347f5743c6d66..a6bc248526510 100644 --- a/x-pack/plugins/event_log/generated/README.md +++ b/x-pack/plugins/event_log/generated/README.md @@ -1,11 +1,26 @@ +# Generating event schema + The files in this directory were generated by manually running the script -../scripts/create-schemas.js from the root directory of the repository. +`../scripts/create-schemas.js` from the root directory of the repository. -These files should not be edited by hand. +**These files should not be edited by hand.** Please follow the following steps: -1. clone the [ECS](https://github.com/elastic/ecs) repo locally so that it resides along side your kibana repo, and checkout the ECS version you wish to support (for example, the `1.6` branch, for version 1.6) -2. In the `x-pack/plugins/event_log/scripts/mappings.js` file you'll want to make th efollowing changes: - 1. Update `EcsKibanaExtensionsMappings` to include the mapping of the fields you wish to add. - 2. Update `EcsEventLogProperties` to include the fields in the generated mappings.json. -3. cd to the `kibana` root folder and run: `node ./x-pack/plugins/event_log/scripts/create_schemas.js` + +1. Clone the [ECS](https://github.com/elastic/ecs) repo locally so that it + resides along side your kibana repo, and checkout the ECS version you wish to + support (for example, the `1.8` branch, for version 1.8). + +2. In the `x-pack/plugins/event_log/scripts/mappings.js` file you'll want to + make the following changes: + - Update `EcsCustomPropertyMappings` to include the mapping of the custom + fields you wish to add. + - Update `EcsPropertiesToGenerate` to include the fields in the generated + `mappings.json`. + - Make sure to list all array fields in `EcsEventLogMultiValuedProperties`. + +3. Cd to the `kibana` root folder and run: + + ```sh + node ./x-pack/plugins/event_log/scripts/create_schemas.js + ``` diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 629c4af567961..f2515d0a6a8fb 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -4,6 +4,10 @@ "@timestamp": { "type": "date" }, + "message": { + "norms": false, + "type": "text" + }, "tags": { "ignore_above": 1024, "type": "keyword", @@ -11,10 +15,6 @@ "isArray": "true" } }, - "message": { - "norms": false, - "type": "text" - }, "ecs": { "properties": { "version": { @@ -23,40 +23,197 @@ } } }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, "event": { "properties": { "action": { "ignore_above": 1024, "type": "keyword" }, - "provider": { + "category": { + "ignore_above": 1024, + "type": "keyword", + "meta": { + "isArray": "true" + } + }, + "code": { "ignore_above": 1024, "type": "keyword" }, - "start": { + "created": { "type": "date" }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, "duration": { "type": "long" }, "end": { "type": "date" }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, "outcome": { "ignore_above": 1024, "type": "keyword" }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, "reason": { "ignore_above": 1024, "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword", + "meta": { + "isArray": "true" + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" } } }, - "error": { + "log": { "properties": { - "message": { - "norms": false, - "type": "text" + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword", + "meta": { + "isArray": "true" + } + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" } } }, @@ -101,6 +258,7 @@ } }, "saved_objects": { + "type": "nested", "properties": { "rel": { "type": "keyword", @@ -118,8 +276,7 @@ "type": "keyword", "ignore_above": 1024 } - }, - "type": "nested" + } } } } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 030815ce3c3d7..31d8b7201cfc6 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -19,7 +19,7 @@ type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> : DeepPartial; }; -export const ECS_VERSION = '1.6.0'; +export const ECS_VERSION = '1.8.0'; // types and config-schema describing the es structures export type IValidatedEvent = TypeOf; @@ -28,27 +28,69 @@ export type IEvent = DeepPartial>; export const EventSchema = schema.maybe( schema.object({ '@timestamp': ecsDate(), - tags: ecsStringMulti(), message: ecsString(), + tags: ecsStringMulti(), ecs: schema.maybe( schema.object({ version: ecsString(), }) ), + error: schema.maybe( + schema.object({ + code: ecsString(), + id: ecsString(), + message: ecsString(), + stack_trace: ecsString(), + type: ecsString(), + }) + ), event: schema.maybe( schema.object({ action: ecsString(), - provider: ecsString(), - start: ecsDate(), + category: ecsStringMulti(), + code: ecsString(), + created: ecsDate(), + dataset: ecsString(), duration: ecsNumber(), end: ecsDate(), + hash: ecsString(), + id: ecsString(), + ingested: ecsDate(), + kind: ecsString(), + module: ecsString(), + original: ecsString(), outcome: ecsString(), + provider: ecsString(), reason: ecsString(), + reference: ecsString(), + risk_score: ecsNumber(), + risk_score_norm: ecsNumber(), + sequence: ecsNumber(), + severity: ecsNumber(), + start: ecsDate(), + timezone: ecsString(), + type: ecsStringMulti(), + url: ecsString(), }) ), - error: schema.maybe( + log: schema.maybe( schema.object({ - message: ecsString(), + level: ecsString(), + logger: ecsString(), + }) + ), + rule: schema.maybe( + schema.object({ + author: ecsStringMulti(), + category: ecsString(), + description: ecsString(), + id: ecsString(), + license: ecsString(), + name: ecsString(), + reference: ecsString(), + ruleset: ecsString(), + uuid: ecsString(), + version: ecsString(), }) ), user: schema.maybe( diff --git a/x-pack/plugins/event_log/scripts/create_schemas.js b/x-pack/plugins/event_log/scripts/create_schemas.js index 98140ef905198..4b91cf6a73622 100755 --- a/x-pack/plugins/event_log/scripts/create_schemas.js +++ b/x-pack/plugins/event_log/scripts/create_schemas.js @@ -27,9 +27,12 @@ function main() { const ecsMappings = readEcsJSONFile(ecsDir, ECS_MAPPINGS_FILE); // add our custom fields - ecsMappings.mappings.properties.kibana = mappings.EcsKibanaExtensionsMappings; + ecsMappings.mappings.properties = { + ...ecsMappings.mappings.properties, + ...mappings.EcsCustomPropertyMappings, + }; - const exportedProperties = mappings.EcsEventLogProperties; + const exportedProperties = mappings.EcsPropertiesToGenerate; const multiValuedProperties = new Set(mappings.EcsEventLogMultiValuedProperties); augmentMappings(ecsMappings.mappings, multiValuedProperties); diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index 3d2deda65f489..a7e5f4ae6cb1e 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -5,87 +5,86 @@ * 2.0. */ -exports.EcsKibanaExtensionsMappings = { - properties: { - // kibana server uuid - server_uuid: { - type: 'keyword', - ignore_above: 1024, - }, - // alerting specific fields - alerting: { - properties: { - instance_id: { - type: 'keyword', - ignore_above: 1024, - }, - action_group_id: { - type: 'keyword', - ignore_above: 1024, - }, - action_subgroup: { - type: 'keyword', - ignore_above: 1024, - }, - status: { - type: 'keyword', - ignore_above: 1024, - }, +/** + * These are mappings of custom properties that are not part of ECS. + * Must not interfere with standard ECS fields and field sets. + */ +exports.EcsCustomPropertyMappings = { + kibana: { + properties: { + // kibana server uuid + server_uuid: { + type: 'keyword', + ignore_above: 1024, }, - }, - // array of saved object references, for "linking" via search - saved_objects: { - type: 'nested', - properties: { - // relation; currently only supports "primary" or not set - rel: { - type: 'keyword', - ignore_above: 1024, + // alerting specific fields + alerting: { + properties: { + instance_id: { + type: 'keyword', + ignore_above: 1024, + }, + action_group_id: { + type: 'keyword', + ignore_above: 1024, + }, + action_subgroup: { + type: 'keyword', + ignore_above: 1024, + }, + status: { + type: 'keyword', + ignore_above: 1024, + }, }, - // relevant kibana space - namespace: { - type: 'keyword', - ignore_above: 1024, - }, - id: { - type: 'keyword', - ignore_above: 1024, - }, - type: { - type: 'keyword', - ignore_above: 1024, + }, + // array of saved object references, for "linking" via search + saved_objects: { + type: 'nested', + properties: { + // relation; currently only supports "primary" or not set + rel: { + type: 'keyword', + ignore_above: 1024, + }, + // relevant kibana space + namespace: { + type: 'keyword', + ignore_above: 1024, + }, + id: { + type: 'keyword', + ignore_above: 1024, + }, + type: { + type: 'keyword', + ignore_above: 1024, + }, }, }, }, }, }; -// ECS and Kibana ECS extension properties to generate -exports.EcsEventLogProperties = [ +/** + * These properties will be added to the generated event schema. + * Here you can specify single fields (log.level) and whole field sets (event). + */ +exports.EcsPropertiesToGenerate = [ '@timestamp', - 'tags', 'message', - 'ecs.version', - 'event.action', - 'event.provider', - 'event.start', - 'event.duration', - 'event.end', - 'event.outcome', // optional, but one of failure, success, unknown - 'event.reason', - 'error.message', + 'tags', + 'ecs', + 'error', + 'event', + 'log.level', + 'log.logger', + 'rule', 'user.name', - 'kibana.server_uuid', - 'kibana.alerting.instance_id', - 'kibana.alerting.action_group_id', - 'kibana.alerting.action_subgroup', - 'kibana.alerting.status', - 'kibana.saved_objects.rel', - 'kibana.saved_objects.namespace', - 'kibana.saved_objects.id', - 'kibana.saved_objects.name', - 'kibana.saved_objects.type', + 'kibana', ]; -// properties that can have multiple values (array vs single value) -exports.EcsEventLogMultiValuedProperties = ['tags']; +/** + * These properties can have multiple values (are arrays in the generated event schema). + */ +exports.EcsEventLogMultiValuedProperties = ['tags', 'event.category', 'event.type', 'rule.author']; diff --git a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts index 0350c47816f6d..bb117dd5c5071 100644 --- a/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts +++ b/x-pack/plugins/fleet/common/services/is_agent_upgradeable.ts @@ -17,13 +17,19 @@ export function isAgentUpgradeable(agent: Agent, kibanaVersion: string) { } else { return false; } - if (agent.unenrollment_started_at || agent.unenrolled_at) return false; - if (!agent.local_metadata.elastic.agent.upgradeable) return false; + if (agent.unenrollment_started_at || agent.unenrolled_at) { + return false; + } + if (!agent.local_metadata.elastic.agent.upgradeable) { + return false; + } // make sure versions are only the number before comparison const agentVersionNumber = semverCoerce(agentVersion); if (!agentVersionNumber) throw new Error('agent version is invalid'); const kibanaVersionNumber = semverCoerce(kibanaVersion); if (!kibanaVersionNumber) throw new Error('kibana version is invalid'); - return semverLt(agentVersionNumber, kibanaVersionNumber); + const isAgentLessThanKibana = semverLt(agentVersionNumber, kibanaVersionNumber); + + return isAgentLessThanKibana; } diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index ab15212431401..a4cca4455a274 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -30,6 +30,7 @@ describe('Fleet - packageToPackagePolicy', () => { index_pattern: [], map: [], lens: [], + ml_module: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/types/index.ts b/x-pack/plugins/fleet/common/types/index.ts index 5c385f938a69e..1984de79a6357 100644 --- a/x-pack/plugins/fleet/common/types/index.ts +++ b/x-pack/plugins/fleet/common/types/index.ts @@ -26,6 +26,9 @@ export interface FleetConfigType { host?: string; ca_sha256?: string; }; + fleet_server?: { + hosts?: string[]; + }; agentPolicyRolloutRateLimitIntervalMs: number; agentPolicyRolloutRateLimitRequestPerInterval: number; }; diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 7e5b799e484d6..6e984b2d0b3da 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -66,9 +66,13 @@ export interface FullAgentPolicy { [key: string]: any; }; }; - fleet?: { - kibana: FullAgentPolicyKibanaConfig; - }; + fleet?: + | { + hosts: string[]; + } + | { + kibana: FullAgentPolicyKibanaConfig; + }; inputs: FullAgentPolicyInput[]; revision?: number; agent?: { diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 5ea997d217888..80fabd51613ae 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -50,6 +50,7 @@ export enum KibanaAssetType { indexPattern = 'index_pattern', map = 'map', lens = 'lens', + mlModule = 'ml_module', } /* @@ -62,6 +63,7 @@ export enum KibanaSavedObjectType { indexPattern = 'index-pattern', map = 'map', lens = 'lens', + mlModule = 'ml-module', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/common/types/models/settings.ts b/x-pack/plugins/fleet/common/types/models/settings.ts index bb345a67bec41..d6932f9a4d83f 100644 --- a/x-pack/plugins/fleet/common/types/models/settings.ts +++ b/x-pack/plugins/fleet/common/types/models/settings.ts @@ -8,9 +8,11 @@ import type { SavedObjectAttributes } from 'src/core/public'; export interface BaseSettings { + has_seen_add_data_notice?: boolean; + fleet_server_hosts: string[]; + // TODO remove as part of https://github.com/elastic/kibana/issues/94303 kibana_urls: string[]; kibana_ca_sha256?: string; - has_seen_add_data_notice?: boolean; } export interface Settings extends BaseSettings { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index b654c513e0afb..4616e92925b3a 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -121,8 +121,13 @@ export interface PostBulkAgentUnenrollRequest { }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUnenrollResponse {} +export type PostBulkAgentUnenrollResponse = Record< + Agent['id'], + { + success: boolean; + error?: string; + } +>; export interface PostAgentUpgradeRequest { params: { @@ -141,8 +146,14 @@ export interface PostBulkAgentUpgradeRequest { version: string; }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PostBulkAgentUpgradeResponse {} + +export type PostBulkAgentUpgradeResponse = Record< + Agent['id'], + { + success: boolean; + error?: string; + } +>; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PostAgentUpgradeResponse {} diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx index 6f1adfc8cf9c1..a46e49233cc99 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/enrollment_instructions/manual/index.tsx @@ -13,6 +13,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import type { EnrollmentAPIKey } from '../../../types'; interface Props { + fleetServerHosts: string[]; kibanaUrl: string; apiKey: EnrollmentAPIKey; kibanaCASha256?: string; @@ -23,14 +24,32 @@ const CommandCode = styled.pre({ overflow: 'scroll', }); +function getfleetServerHostsEnrollArgs(apiKey: EnrollmentAPIKey, fleetServerHosts: string[]) { + return `--url=${fleetServerHosts[0]} --enrollment-token=${apiKey.api_key}`; +} + +function getKibanaUrlEnrollArgs( + apiKey: EnrollmentAPIKey, + kibanaUrl: string, + kibanaCASha256?: string +) { + return `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${ + kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' + }`; +} + export const ManualInstructions: React.FunctionComponent = ({ kibanaUrl, apiKey, kibanaCASha256, + fleetServerHosts, }) => { - const enrollArgs = `--kibana-url=${kibanaUrl} --enrollment-token=${apiKey.api_key}${ - kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' - }`; + const fleetServerHostsNotEmpty = fleetServerHosts.length > 0; + + const enrollArgs = fleetServerHostsNotEmpty + ? getfleetServerHostsEnrollArgs(apiKey, fleetServerHosts) + : // TODO remove as part of https://github.com/elastic/kibana/issues/94303 + getKibanaUrlEnrollArgs(apiKey, kibanaUrl, kibanaCASha256); const linuxMacCommand = `./elastic-agent install -f ${enrollArgs}`; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx deleted file mode 100644 index 146f40cd75d49..0000000000000 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout.tsx +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiSpacer, - EuiButton, - EuiFlyoutFooter, - EuiForm, - EuiFormRow, - EuiComboBox, - EuiCodeEditor, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiText } from '@elastic/eui'; -import { safeLoad } from 'js-yaml'; - -import { - useComboInput, - useStartServices, - useGetSettings, - useInput, - sendPutSettings, -} from '../hooks'; -import { useGetOutputs, sendPutOutput } from '../hooks/use_request/outputs'; -import { isDiffPathProtocol } from '../../../../common/'; - -const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm; - -interface Props { - onClose: () => void; -} - -function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { - const [isLoading, setIsloading] = React.useState(false); - const { notifications } = useStartServices(); - const kibanaUrlsInput = useComboInput([], (value) => { - if (value.length === 0) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', { - defaultMessage: 'At least one URL is required', - }), - ]; - } - if (value.some((v) => !v.match(URL_REGEX))) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlError', { - defaultMessage: 'Invalid URL', - }), - ]; - } - if (isDiffPathProtocol(value)) { - return [ - i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', { - defaultMessage: 'Protocol and path must be the same for each URL', - }), - ]; - } - }); - const elasticsearchUrlInput = useComboInput([], (value) => { - if (value.some((v) => !v.match(URL_REGEX))) { - return [ - i18n.translate('xpack.fleet.settings.elasticHostError', { - defaultMessage: 'Invalid URL', - }), - ]; - } - }); - - const additionalYamlConfigInput = useInput('', (value) => { - try { - safeLoad(value); - return; - } catch (error) { - return [ - i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', { - defaultMessage: 'Invalid YAML: {reason}', - values: { reason: error.message }, - }), - ]; - } - }); - return { - isLoading, - onSubmit: async () => { - if ( - !kibanaUrlsInput.validate() || - !elasticsearchUrlInput.validate() || - !additionalYamlConfigInput.validate() - ) { - return; - } - - try { - setIsloading(true); - if (!outputId) { - throw new Error('Unable to load outputs'); - } - const outputResponse = await sendPutOutput(outputId, { - hosts: elasticsearchUrlInput.value, - config_yaml: additionalYamlConfigInput.value, - }); - if (outputResponse.error) { - throw outputResponse.error; - } - const settingsResponse = await sendPutSettings({ - kibana_urls: kibanaUrlsInput.value, - }); - if (settingsResponse.error) { - throw settingsResponse.error; - } - notifications.toasts.addSuccess( - i18n.translate('xpack.fleet.settings.success.message', { - defaultMessage: 'Settings saved', - }) - ); - setIsloading(false); - onSuccess(); - } catch (error) { - setIsloading(false); - notifications.toasts.addError(error, { - title: 'Error', - }); - } - }, - inputs: { - kibanaUrls: kibanaUrlsInput, - elasticsearchUrl: elasticsearchUrlInput, - additionalYamlConfig: additionalYamlConfigInput, - }, - }; -} - -export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { - const settingsRequest = useGetSettings(); - const settings = settingsRequest?.data?.item; - const outputsRequest = useGetOutputs(); - const output = outputsRequest.data?.items?.[0]; - const { inputs, onSubmit, isLoading } = useSettingsForm(output?.id, onClose); - - useEffect(() => { - if (output) { - inputs.elasticsearchUrl.setValue(output.hosts || []); - inputs.additionalYamlConfig.setValue( - output.config_yaml || - `# YAML settings here will be added to the Elasticsearch output section of each policy` - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [output]); - - useEffect(() => { - if (settings) { - inputs.kibanaUrls.setValue(settings.kibana_urls); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings]); - - const body = ( - - -

- -

-
- - - - - - - - - - - - - - - - - - - - - - -
- ); - - return ( - - - -

- -

-
-
- {body} - - - - - - - - - - - - - - -
- ); -}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx new file mode 100644 index 0000000000000..8bef32916452f --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/confirm_modal.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalFooter, + EuiModalBody, + EuiCallOut, + EuiButton, + EuiButtonEmpty, + EuiBasicTable, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import type { EuiBasicTableProps } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export interface SettingsConfirmModalProps { + changes: Array<{ + type: 'elasticsearch' | 'fleet_server'; + direction: 'removed' | 'added'; + urls: string[]; + }>; + onConfirm: () => void; + onClose: () => void; +} + +type Change = SettingsConfirmModalProps['changes'][0]; + +const TABLE_COLUMNS: EuiBasicTableProps['columns'] = [ + { + name: i18n.translate('xpack.fleet.settingsConfirmModal.fieldLabel', { + defaultMessage: 'Field', + }), + field: 'label', + render: (_, item) => getLabel(item), + width: '180px', + }, + { + field: 'urls', + name: i18n.translate('xpack.fleet.settingsConfirmModal.valueLabel', { + defaultMessage: 'Value', + }), + render: (_, item) => { + return ( + + {item.urls.map((url) => ( +
{url}
+ ))} +
+ ); + }, + }, +]; + +function getLabel(change: Change) { + if (change.type === 'elasticsearch' && change.direction === 'removed') { + return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchRemovedLabel', { + defaultMessage: 'Elasticsearch hosts (old)', + }); + } + + if (change.type === 'elasticsearch' && change.direction === 'added') { + return i18n.translate('xpack.fleet.settingsConfirmModal.elasticsearchAddedLabel', { + defaultMessage: 'Elasticsearch hosts (new)', + }); + } + + if (change.type === 'fleet_server' && change.direction === 'removed') { + return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerRemovedLabel', { + defaultMessage: 'Fleet Server hosts (old)', + }); + } + + if (change.type === 'fleet_server' && change.direction === 'added') { + return i18n.translate('xpack.fleet.settingsConfirmModal.fleetServerAddedLabel', { + defaultMessage: 'Fleet Server hosts (new)', + }); + } + + return i18n.translate('xpack.fleet.settingsConfirmModal.defaultChangeLabel', { + defaultMessage: 'Unknown setting', + }); +} + +export const SettingsConfirmModal = React.memo( + ({ changes, onConfirm, onClose }) => { + const hasESChanges = changes.some((change) => change.type === 'elasticsearch'); + const hasFleetServerChanges = changes.some((change) => change.type === 'fleet_server'); + + return ( + + + + + + + + + + } + color="warning" + iconType="alert" + > + + {hasFleetServerChanges && ( +

+ + + + ), + }} + /> +

+ )} + + {hasESChanges && ( +

+ + + + ), + }} + /> +

+ )} +
+
+ + {changes.length > 0 && ( + <> + + + + )} +
+ + + + + + + + + +
+ ); + } +); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx new file mode 100644 index 0000000000000..faf8707f2efc1 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -0,0 +1,439 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiSpacer, + EuiButton, + EuiFlyoutFooter, + EuiForm, + EuiFormRow, + EuiComboBox, + EuiCode, + EuiCodeEditor, + EuiLink, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +import { safeLoad } from 'js-yaml'; + +import { + useComboInput, + useStartServices, + useGetSettings, + useInput, + sendPutSettings, +} from '../../hooks'; +import { useGetOutputs, sendPutOutput } from '../../hooks/use_request/outputs'; +import { isDiffPathProtocol } from '../../../../../common/'; + +import { SettingsConfirmModal } from './confirm_modal'; +import type { SettingsConfirmModalProps } from './confirm_modal'; + +const URL_REGEX = /^(https?):\/\/[^\s$.?#].[^\s]*$/gm; + +interface Props { + onClose: () => void; +} + +function isSameArrayValue(arrayA: string[] = [], arrayB: string[] = []) { + return arrayA.length === arrayB.length && arrayA.every((val, index) => val === arrayB[index]); +} + +function useSettingsForm(outputId: string | undefined, onSuccess: () => void) { + const [isLoading, setIsloading] = React.useState(false); + const { notifications } = useStartServices(); + const kibanaUrlsInput = useComboInput([], (value) => { + if (value.length === 0) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlEmptyError', { + defaultMessage: 'At least one URL is required', + }), + ]; + } + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + if (isDiffPathProtocol(value)) { + return [ + i18n.translate('xpack.fleet.settings.kibanaUrlDifferentPathOrProtocolError', { + defaultMessage: 'Protocol and path must be the same for each URL', + }), + ]; + } + }); + const fleetServerHostsInput = useComboInput([], (value) => { + // TODO enable as part of https://github.com/elastic/kibana/issues/94303 + // if (value.length === 0) { + // return [ + // i18n.translate('xpack.fleet.settings.fleetServerHostsEmptyError', { + // defaultMessage: 'At least one URL is required', + // }), + // ]; + // } + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.fleetServerHostsError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + if (value.length && isDiffPathProtocol(value)) { + return [ + i18n.translate('xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError', { + defaultMessage: 'Protocol and path must be the same for each URL', + }), + ]; + } + }); + + const elasticsearchUrlInput = useComboInput([], (value) => { + if (value.some((v) => !v.match(URL_REGEX))) { + return [ + i18n.translate('xpack.fleet.settings.elasticHostError', { + defaultMessage: 'Invalid URL', + }), + ]; + } + }); + + const additionalYamlConfigInput = useInput('', (value) => { + try { + safeLoad(value); + return; + } catch (error) { + return [ + i18n.translate('xpack.fleet.settings.invalidYamlFormatErrorMessage', { + defaultMessage: 'Invalid YAML: {reason}', + values: { reason: error.message }, + }), + ]; + } + }); + + const validate = useCallback(() => { + if ( + !kibanaUrlsInput.validate() || + !fleetServerHostsInput.validate() || + !elasticsearchUrlInput.validate() || + !additionalYamlConfigInput.validate() + ) { + return false; + } + + return true; + }, [kibanaUrlsInput, fleetServerHostsInput, elasticsearchUrlInput, additionalYamlConfigInput]); + + return { + isLoading, + validate, + submit: async () => { + try { + setIsloading(true); + if (!outputId) { + throw new Error('Unable to load outputs'); + } + const outputResponse = await sendPutOutput(outputId, { + hosts: elasticsearchUrlInput.value, + config_yaml: additionalYamlConfigInput.value, + }); + if (outputResponse.error) { + throw outputResponse.error; + } + const settingsResponse = await sendPutSettings({ + kibana_urls: kibanaUrlsInput.value, + fleet_server_hosts: fleetServerHostsInput.value, + }); + if (settingsResponse.error) { + throw settingsResponse.error; + } + notifications.toasts.addSuccess( + i18n.translate('xpack.fleet.settings.success.message', { + defaultMessage: 'Settings saved', + }) + ); + setIsloading(false); + onSuccess(); + } catch (error) { + setIsloading(false); + notifications.toasts.addError(error, { + title: 'Error', + }); + } + }, + inputs: { + fleetServerHosts: fleetServerHostsInput, + kibanaUrls: kibanaUrlsInput, + elasticsearchUrl: elasticsearchUrlInput, + additionalYamlConfig: additionalYamlConfigInput, + }, + }; +} + +export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { + const settingsRequest = useGetSettings(); + const settings = settingsRequest?.data?.item; + const outputsRequest = useGetOutputs(); + const output = outputsRequest.data?.items?.[0]; + const { inputs, submit, validate, isLoading } = useSettingsForm(output?.id, onClose); + + const [isConfirmModalVisible, setConfirmModalVisible] = React.useState(false); + + const onSubmit = useCallback(() => { + if (validate()) { + setConfirmModalVisible(true); + } + }, [validate, setConfirmModalVisible]); + + const onConfirm = useCallback(() => { + setConfirmModalVisible(false); + submit(); + }, [submit]); + + const onConfirmModalClose = useCallback(() => { + setConfirmModalVisible(false); + }, [setConfirmModalVisible]); + + useEffect(() => { + if (output) { + inputs.elasticsearchUrl.setValue(output.hosts || []); + inputs.additionalYamlConfig.setValue(output.config_yaml || ''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [output]); + + useEffect(() => { + if (settings) { + inputs.kibanaUrls.setValue([...settings.kibana_urls]); + inputs.fleetServerHosts.setValue([...settings.fleet_server_hosts]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings]); + + const isUpdated = React.useMemo(() => { + if (!settings || !output) { + return false; + } + return ( + !isSameArrayValue(settings.kibana_urls, inputs.kibanaUrls.value) || + !isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value) || + !isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value) || + (output.config_yaml || '') !== inputs.additionalYamlConfig.value + ); + }, [settings, inputs, output]); + + const changes = React.useMemo(() => { + if (!settings || !output || !isConfirmModalVisible) { + return []; + } + + const tmpChanges: SettingsConfirmModalProps['changes'] = []; + if (!isSameArrayValue(output.hosts, inputs.elasticsearchUrl.value)) { + tmpChanges.push( + { + type: 'elasticsearch', + direction: 'removed', + urls: output.hosts || [], + }, + { + type: 'elasticsearch', + direction: 'added', + urls: inputs.elasticsearchUrl.value, + } + ); + } + + if (!isSameArrayValue(settings.fleet_server_hosts, inputs.fleetServerHosts.value)) { + tmpChanges.push( + { + type: 'fleet_server', + direction: 'removed', + urls: settings.fleet_server_hosts, + }, + { + type: 'fleet_server', + direction: 'added', + urls: inputs.fleetServerHosts.value, + } + ); + } + + return tmpChanges; + }, [settings, inputs, output, isConfirmModalVisible]); + + const body = settings && ( + + +

+ +

+
+ + + outputs, + }} + /> + + + + + + + ), + }} + /> + } + {...inputs.fleetServerHosts.formRowProps} + > + + + + + {/* // TODO remove as part of https://github.com/elastic/kibana/issues/94303 */} + + + + + + + + + + + +
+ ); + + return ( + <> + {isConfirmModalVisible && ( + + )} + + + +

+ +

+
+
+ {body} + + + + + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + +
+ + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx index ba6367a861e9d..4a7e738ec540a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/layouts/default.tsx @@ -125,7 +125,7 @@ export const DefaultLayout: React.FunctionComponent = ({ setIsSettingsFlyoutOpen(true)}> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx index 4925f60f19e26..0ca6b223b3492 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_enrollment_flyout/managed_instructions.tsx @@ -56,6 +56,7 @@ export const ManagedInstructions = React.memo(({ agentPolicies }) => { apiKey={apiKey.data.item} kibanaUrl={kibanaUrl} kibanaCASha256={kibanaCASha256} + fleetServerHosts={settings.data?.item?.fleet_server_hosts || []} /> ), }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx index de7e16e1e5d2b..ea19a330adfee 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/constants.tsx @@ -33,6 +33,7 @@ export const AssetTitleMap: Record = { map: 'Map', data_stream_ilm_policy: 'Data Stream ILM Policy', lens: 'Lens', + ml_module: 'ML Module', }; export const ServiceTitleMap: Record = { @@ -47,6 +48,7 @@ export const AssetIcons: Record = { visualization: 'visualizeApp', map: 'emsApp', lens: 'lensApp', + ml_module: 'mlApp', }; export const ServiceIcons: Record = { diff --git a/x-pack/plugins/fleet/server/index.ts b/x-pack/plugins/fleet/server/index.ts index 8bad868b813ac..0178b801f4d2f 100644 --- a/x-pack/plugins/fleet/server/index.ts +++ b/x-pack/plugins/fleet/server/index.ts @@ -65,6 +65,11 @@ export const config: PluginConfigDescriptor = { host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), }), + fleet_server: schema.maybe( + schema.object({ + hosts: schema.maybe(schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }))), + }) + ), agentPolicyRolloutRateLimitIntervalMs: schema.number({ defaultValue: AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS, }), diff --git a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts index 558a9a8afbb0b..1505955215515 100644 --- a/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/unenroll_handler.ts @@ -60,11 +60,17 @@ export const postBulkAgentsUnenrollHandler: RequestHandler< : { kuery: request.body.agents }; try { - await AgentService.unenrollAgents(soClient, esClient, { + const results = await AgentService.unenrollAgents(soClient, esClient, { ...agentOptions, force: request.body?.force, }); - const body: PostBulkAgentUnenrollResponse = {}; + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, + }; + return acc; + }, {}); return response.ok({ body }); } catch (error) { diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index b8af265883091..52f62037f61e6 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -99,9 +99,15 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< version, force, }; - await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); + const results = await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); + const body = results.items.reduce((acc, so) => { + acc[so.id] = { + success: !so.error, + error: so.error?.message, + }; + return acc; + }, {}); - const body: PostBulkAgentUpgradeResponse = {}; return response.ok({ body }); } catch (error) { return defaultIngestErrorHandler({ error, response }); diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index e5f0537a8c27a..87ca9782ab698 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -58,9 +58,11 @@ const getSavedObjectTypes = ( }, mappings: { properties: { + fleet_server_hosts: { type: 'keyword' }, + has_seen_add_data_notice: { type: 'boolean', index: false }, + // TODO remove as part of https://github.com/elastic/kibana/issues/94303 kibana_urls: { type: 'keyword' }, kibana_ca_sha256: { type: 'keyword' }, - has_seen_add_data_notice: { type: 'boolean', index: false }, }, }, migrations: { diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index 515d2b1195638..56e76130538cf 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -171,6 +171,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', @@ -206,6 +207,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', @@ -242,6 +244,7 @@ describe('agent policy', () => { inputs: [], revision: 1, fleet: { + hosts: ['http://localhost:5603'], kibana: { hosts: ['localhost:5603'], protocol: 'http', diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 2cafe2fe57c01..357b9150407ef 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -706,12 +706,20 @@ class AgentPolicyService { } catch (error) { throw new Error('Default settings is not setup'); } - if (!settings.kibana_urls || !settings.kibana_urls.length) - throw new Error('kibana_urls is missing'); - - fullAgentPolicy.fleet = { - kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), - }; + if (settings.fleet_server_hosts && settings.fleet_server_hosts.length) { + fullAgentPolicy.fleet = { + hosts: settings.fleet_server_hosts, + }; + } // TODO remove as part of https://github.com/elastic/kibana/issues/94303 + else { + if (!settings.kibana_urls || !settings.kibana_urls.length) + throw new Error('kibana_urls is missing'); + + fullAgentPolicy.fleet = { + hosts: settings.kibana_urls, + kibana: getFullAgentPolicyKibanaConfig(settings.kibana_urls), + }; + } } return fullAgentPolicy; } diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index b89b2b6d351b8..ecf18430da668 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -10,7 +10,6 @@ import type { estypes } from '@elastic/elasticsearch'; import type { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; import type { AgentSOAttributes, Agent, BulkActionResult, ListWithKuery } from '../../types'; - import { appContextService, agentPolicyService } from '../../services'; import type { FleetServerAgent } from '../../../common'; import { isAgentUpgradeable, SO_SEARCH_LIMIT } from '../../../common'; diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 8cf7396eaa8de..ff243eff11570 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -7,6 +7,7 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { AgentUnenrollmentError } from '../../errors'; @@ -57,26 +58,35 @@ export async function unenrollAgents( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: GetAgentsOptions & { force?: boolean } -) { +): Promise<{ items: BulkActionResult[] }> { // start with all agents specified - const agents = await getAgents(esClient, options); + const givenAgents = await getAgents(esClient, options); + const outgoingErrors: Record = {}; // Filter to those not already unenrolled, or unenrolling - const agentsEnrolled = agents.filter((agent) => { + const agentsEnrolled = givenAgents.filter((agent) => { if (options.force) { return !agent.unenrolled_at; } return !agent.unenrollment_started_at && !agent.unenrolled_at; }); // And which are allowed to unenroll - const settled = await Promise.allSettled( + const agentResults = await Promise.allSettled( agentsEnrolled.map((agent) => unenrollAgentIsAllowed(soClient, esClient, agent.id).then((_) => agent) ) ); - const agentsToUpdate = agentsEnrolled.filter((_, index) => settled[index].status === 'fulfilled'); - const now = new Date().toISOString(); + const agentsToUpdate = agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; + } + return agents; + }, []); + const now = new Date().toISOString(); if (options.force) { // Get all API keys that need to be invalidated const apiKeys = agentsToUpdate.reduce((keys, agent) => { @@ -94,17 +104,6 @@ export async function unenrollAgents( if (apiKeys.length) { await APIKeyService.invalidateAPIKeys(soClient, apiKeys); } - // Update the necessary agents - return bulkUpdateAgents( - esClient, - agentsToUpdate.map((agent) => ({ - agentId: agent.id, - data: { - active: false, - unenrolled_at: now, - }, - })) - ); } else { // Create unenroll action for each agent await bulkCreateAgentActions( @@ -116,18 +115,32 @@ export async function unenrollAgents( type: 'UNENROLL', })) ); - - // Update the necessary agents - return bulkUpdateAgents( - esClient, - agentsToUpdate.map((agent) => ({ - agentId: agent.id, - data: { - unenrollment_started_at: now, - }, - })) - ); } + + // Update the necessary agents + const updateData = options.force + ? { unenrolled_at: now, active: false } + : { unenrollment_started_at: now }; + + await bulkUpdateAgents( + esClient, + agentsToUpdate.map(({ id }) => ({ agentId: id, data: updateData })) + ); + + const out = { + items: givenAgents.map((agent, index) => { + const hasError = agent.id in outgoingErrors; + const result: BulkActionResult = { + id: agent.id, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agent.id]; + } + return result; + }), + }; + return out; } export async function forceUnenrollAgent( diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 6c3b404a5b6f3..14b8dfaed4d91 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -7,16 +7,23 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; -import type { AgentAction, AgentActionSOAttributes } from '../../types'; +import type { Agent, AgentAction, AgentActionSOAttributes, BulkActionResult } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; import { agentPolicyService } from '../../services'; -import { IngestManagerError } from '../../errors'; +import { AgentReassignmentError, IngestManagerError } from '../../errors'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; import { bulkCreateAgentActions, createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; -import { getAgents, updateAgent, bulkUpdateAgents, getAgentPolicyForAgent } from './crud'; +import { + getAgentDocuments, + getAgents, + updateAgent, + bulkUpdateAgents, + getAgentPolicyForAgent, +} from './crud'; +import { searchHitToAgent } from './helpers'; export async function sendUpgradeAgentAction({ soClient, @@ -77,39 +84,75 @@ export async function ackAgentUpgraded( export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, - options: GetAgentsOptions & { + options: ({ agents: Agent[] } | GetAgentsOptions) & { sourceUri: string | undefined; version: string; force?: boolean; } ) { // Full set of agents - const agentsGiven = await getAgents(esClient, options); + const outgoingErrors: Record = {}; + let givenAgents: Agent[] = []; + if ('agents' in options) { + givenAgents = options.agents; + } else if ('agentIds' in options) { + const givenAgentsResults = await getAgentDocuments(esClient, options.agentIds); + for (const agentResult of givenAgentsResults) { + if (agentResult.found === false) { + outgoingErrors[agentResult._id] = new AgentReassignmentError( + `Cannot find agent ${agentResult._id}` + ); + } else { + givenAgents.push(searchHitToAgent(agentResult)); + } + } + } else if ('kuery' in options) { + givenAgents = await getAgents(esClient, options); + } + const givenOrder = + 'agentIds' in options ? options.agentIds : givenAgents.map((agent) => agent.id); + + // get any policy ids from upgradable agents + const policyIdsToGet = new Set( + givenAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) + ); + + // get the agent policies for those ids + const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { + fields: ['is_managed'], + }); + const managedPolicies = agentPolicies.reduce>((acc, policy) => { + acc[policy.id] = policy.is_managed; + return acc; + }, {}); // Filter out agents currently unenrolling, unenrolled, or not upgradeable b/c of version check const kibanaVersion = appContextService.getKibanaVersion(); - const upgradeableAgents = options.force - ? agentsGiven - : agentsGiven.filter((agent) => isAgentUpgradeable(agent, kibanaVersion)); - - if (!options.force) { - // get any policy ids from upgradable agents - const policyIdsToGet = new Set( - upgradeableAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) - ); - - // get the agent policies for those ids - const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { - fields: ['is_managed'], - }); + const agentResults = await Promise.allSettled( + givenAgents.map(async (agent) => { + const isAllowed = options.force || isAgentUpgradeable(agent, kibanaVersion); + if (!isAllowed) { + throw new IngestManagerError(`${agent.id} is not upgradeable`); + } - // throw if any of those agent policies are managed - for (const policy of agentPolicies) { - if (policy.is_managed) { - throw new IngestManagerError(`Cannot upgrade agent in managed policy ${policy.id}`); + if (!options.force && agent.policy_id && managedPolicies[agent.policy_id]) { + throw new IngestManagerError(`Cannot upgrade agent in managed policy ${agent.policy_id}`); } + return agent; + }) + ); + + // Filter to agents that do not already use the new agent policy ID + const agentsToUpdate = agentResults.reduce((agents, result, index) => { + if (result.status === 'fulfilled') { + agents.push(result.value); + } else { + const id = givenAgents[index].id; + outgoingErrors[id] = result.reason; } - } + return agents; + }, []); + // Create upgrade action for each agent const now = new Date().toISOString(); const data = { @@ -120,7 +163,7 @@ export async function sendUpgradeAgentsActions( await bulkCreateAgentActions( soClient, esClient, - upgradeableAgents.map((agent) => ({ + agentsToUpdate.map((agent) => ({ agent_id: agent.id, created_at: now, data, @@ -129,9 +172,9 @@ export async function sendUpgradeAgentsActions( })) ); - return await bulkUpdateAgents( + await bulkUpdateAgents( esClient, - upgradeableAgents.map((agent) => ({ + agentsToUpdate.map((agent) => ({ agentId: agent.id, data: { upgraded_at: null, @@ -139,4 +182,17 @@ export async function sendUpgradeAgentsActions( }, })) ); + const orderedOut = givenOrder.map((agentId) => { + const hasError = agentId in outgoingErrors; + const result: BulkActionResult = { + id: agentId, + success: !hasError, + }; + if (hasError) { + result.error = outgoingErrors[agentId]; + } + return result; + }); + + return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 4196138a2534f..bfcc40e18fe80 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -37,6 +37,7 @@ const KibanaSavedObjectTypeMapping: Record { diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json index 9937e9ad66e56..58ae1a2e00ea4 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json @@ -187,6 +187,9 @@ "policy_id": { "type": "keyword" }, + "policy_output_permissions_hash": { + "type": "keyword" + }, "policy_revision_idx": { "type": "integer" }, diff --git a/x-pack/plugins/fleet/server/services/settings.ts b/x-pack/plugins/fleet/server/services/settings.ts index 03348a2fcc4bb..7658a8d71839e 100644 --- a/x-pack/plugins/fleet/server/services/settings.ts +++ b/x-pack/plugins/fleet/server/services/settings.ts @@ -27,6 +27,7 @@ export async function getSettings(soClient: SavedObjectsClientContract): Promise return { id: settingsSo.id, ...settingsSo.attributes, + fleet_server_hosts: settingsSo.attributes.fleet_server_hosts || [], }; } @@ -81,7 +82,10 @@ export function createDefaultSettings(): BaseSettings { pathname: basePath.serverBasePath, }); + const fleetServerHosts = appContextService.getConfig()?.agents?.fleet_server?.hosts ?? []; + return { kibana_urls: [cloudUrl || flagsUrl || defaultUrl].flat(), + fleet_server_hosts: fleetServerHosts, }; } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts index 9bbebbe86ccaa..9051d7a06efff 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/settings.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/settings.ts @@ -13,6 +13,15 @@ export const GetSettingsRequestSchema = {}; export const PutSettingsRequestSchema = { body: schema.object({ + fleet_server_hosts: schema.maybe( + schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { + validate: (value) => { + if (value.length && isDiffPathProtocol(value)) { + return 'Protocol and path must be the same for each URL'; + } + }, + }) + ), kibana_urls: schema.maybe( schema.arrayOf(schema.uri({ scheme: ['http', 'https'] }), { validate: (value) => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx index 6b3982cb50c59..b2647b175b324 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; -import { uiSettingsServiceMock } from '../../../../../../../../../../src/core/public/mocks'; +import { + docLinksServiceMock, + uiSettingsServiceMock, +} from '../../../../../../../../../../src/core/public/mocks'; import { MappingsEditorProvider } from '../../../mappings_editor_context'; import { createKibanaReactContext } from '../../../shared_imports'; @@ -80,10 +83,7 @@ const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ }); const defaultProps = { - docLinks: { - DOC_LINK_VERSION: 'master', - ELASTIC_WEBSITE_URL: 'https://jest.elastic.co', - }, + docLinks: docLinksServiceMock.createStartContract(), }; export const WithAppDependencies = (Comp: any) => (props: any) => ( diff --git a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx index 124b6b8f13bf9..33fbbd03d790a 100644 --- a/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx +++ b/x-pack/plugins/infra/public/utils/enzyme_helpers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { act as reactAct } from 'react-dom/test-utils'; diff --git a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts index b06752ee0a80d..c16d65a75b3e0 100644 --- a/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts +++ b/x-pack/plugins/infra/server/services/log_entries/message/builtin_rules/generic.ts @@ -45,6 +45,37 @@ export const getGenericRules = (genericMessageFields: string[]) => [ ]; const createGenericRulesForField = (fieldName: string) => [ + { + when: { + exists: ['event.dataset', 'log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'event.dataset', + }, + { + constant: '][', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['event.dataset', 'log.level', fieldName], @@ -70,6 +101,31 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: ['log.level', fieldName, 'error.stack_trace.text'], + }, + format: [ + { + constant: '[', + }, + { + field: 'log.level', + }, + { + constant: '] ', + }, + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: ['log.level', fieldName], @@ -89,6 +145,22 @@ const createGenericRulesForField = (fieldName: string) => [ }, ], }, + { + when: { + exists: [fieldName, 'error.stack_trace.text'], + }, + format: [ + { + field: fieldName, + }, + { + constant: '\n', + }, + { + field: 'error.stack_trace.text', + }, + ], + }, { when: { exists: [fieldName], diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx new file mode 100644 index 0000000000000..c6449dbd7a93e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/bytes.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +// Default parameter values automatically added to the Bytes processor when saved +const defaultBytesParameters = { + ignore_failure: undefined, + description: undefined, +}; + +const BYTES_TYPE = 'bytes'; + +describe('Processor: Bytes', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + testBed.component.update(); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is required parameter + expect(form.getErrorsMessages()).toEqual(['A field value is required.']); + }); + + test('saves with default parameter values', async () => { + const { + actions: { addProcessor, saveNewProcessor, addProcessorType }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + field: 'field_1', + ...defaultBytesParameters, + }); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { addProcessor, addProcessorType, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await addProcessorType(BYTES_TYPE); + // Add "field" value (required) + form.setInputValue('fieldNameField.input', 'field_1'); + + // Set optional parameteres + form.setInputValue('targetField.input', 'target_field'); + + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, BYTES_TYPE); + expect(processors[0].bytes).toEqual({ + description: undefined, + field: 'field_1', + ignore_failure: undefined, + target_field: 'target_field', + ignore_missing: true, + tag: undefined, + if: undefined, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index c08627de636d7..8340cf45b1f1b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -90,9 +90,9 @@ const createActions = (testBed: TestBed) => { component.update(); }, - async addProcessorType({ type, label }: { type: string; label: string }) { + async addProcessorType(type: string) { await act(async () => { - find('processorTypeSelector.input').simulate('change', [{ value: type, label }]); + find('processorTypeSelector.input').simulate('change', [{ value: type }]); }); component.update(); }, @@ -127,12 +127,19 @@ export const setupEnvironment = () => { }; }; +export const getProcessorValue = (onUpdate: jest.Mock, type: string) => { + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { processors } = onUpdateResult.getData(); + return processors; +}; + type TestSubject = | 'addProcessorForm.submitButton' | 'addProcessorButton' | 'addProcessorForm.submitButton' | 'processorTypeSelector.input' | 'fieldNameField.input' + | 'ignoreMissingSwitch.input' | 'targetField.input' | 'keepOriginalField.input' | 'removeIfSuccessfulField.input'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx new file mode 100644 index 0000000000000..de0061dcb0407 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor_form.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult } from './processor.helpers'; + +describe('Processor: Bytes', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + testBed.component.update(); + }); + + test('Prevents form submission if processor type not selected', async () => { + const { + actions: { addProcessor, saveNewProcessor }, + form, + } = testBed; + + // Open flyout to add new processor + addProcessor(); + // Click submit button without entering any fields + await saveNewProcessor(); + + // Expect form error as a processor type is required + expect(form.getErrorsMessages()).toEqual(['A type is required.']); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx index 41078b7e96df9..573adad3247f5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx @@ -6,7 +6,7 @@ */ import { act } from 'react-dom/test-utils'; -import { setup, SetupResult } from './processor.helpers'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; // Default parameter values automatically added to the URI parts processor when saved const defaultUriPartsParameters = { @@ -16,6 +16,8 @@ const defaultUriPartsParameters = { description: undefined, }; +const URI_PARTS_TYPE = 'uri_parts'; + describe('Processor: URI parts', () => { let onUpdate: jest.Mock; let testBed: SetupResult; @@ -51,14 +53,9 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); - // Click submit button without entering any fields - await saveNewProcessor(); - - // Expect form error as a processor type is required - expect(form.getErrorsMessages()).toEqual(['A type is required.']); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Click submit button with only the type defined await saveNewProcessor(); @@ -76,14 +73,13 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); // Save the field await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ field: 'field_1', ...defaultUriPartsParameters, @@ -99,7 +95,7 @@ describe('Processor: URI parts', () => { // Open flyout to add new processor addProcessor(); // Add type (the other fields are not visible until a type is selected) - await addProcessorType({ type: 'uri_parts', label: 'URI parts' }); + await addProcessorType(URI_PARTS_TYPE); // Add "field" value (required) form.setInputValue('fieldNameField.input', 'field_1'); @@ -111,8 +107,7 @@ describe('Processor: URI parts', () => { // Save the field with new changes await saveNewProcessor(); - const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; - const { processors } = onUpdateResult.getData(); + const processors = getProcessorValue(onUpdate, URI_PARTS_TYPE); expect(processors[0].uri_parts).toEqual({ description: undefined, field: 'field_1', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx index 82e086102b488..744e9798c4fb0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx @@ -50,5 +50,6 @@ export const IgnoreMissingField: FunctionComponent = (props) => ( config={{ ...fieldsConfig.ignore_missing, ...props }} component={ToggleField} path="fields.ignore_missing" + data-test-subj="ignoreMissingSwitch" /> ); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 38bcf8a377bf2..20bf349f6b13a 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -72,7 +72,7 @@ const { TopNavMenu } = navigationStartMock.ui; function createMockFrame(): jest.Mocked { return { - mount: jest.fn((el, props) => {}), + mount: jest.fn(async (el, props) => {}), unmount: jest.fn(() => {}), }; } diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index 7aa838021f2a8..7de406aee2534 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -1,24 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DragDrop defined dropType is reflected in the className 1`] = ` - + +
`; -exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` - + + `; exports[`DragDrop renders if nothing is being dragged 1`] = ` diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index 961f7ee0ec400..57ebe79af2219 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -3,7 +3,9 @@ .lnsDragDrop { user-select: none; - transition: background-color $euiAnimSpeedFast ease-in-out, border-color $euiAnimSpeedFast ease-in-out; + transition: $euiAnimSpeedFast ease-in-out; + transition-property: background-color, border-color, opacity; + z-index: $euiZLevel1; } .lnsDragDrop_ghost { @@ -16,7 +18,7 @@ left: 0; opacity: .9; transform: translate(-12px, 8px); - z-index: $euiZLevel2; + z-index: $euiZLevel3; pointer-events: none; box-shadow: 0 0 0 $euiFocusRingSize $euiFocusRingColor; } @@ -56,6 +58,7 @@ // Drop area while hovering with item .lnsDragDrop-isActiveDropTarget { + z-index: $euiZLevel3; @include lnsDroppableActiveHover; } @@ -81,6 +84,16 @@ } } +.lnsDragDrop__container { + position: relative; + width: 100%; + height: 100%; + + &.lnsDragDrop__container-active { + z-index: $euiZLevel3; + } +} + .lnsDragDrop__reorderableDrop { position: absolute; width: 100%; @@ -92,6 +105,14 @@ transform: translateY(0); transition: transform $euiAnimSpeedFast ease-in-out; pointer-events: none; + + .lnsDragDrop-isDropTarget { + @include lnsDraggable; + } + + .lnsDragDrop-isActiveDropTarget { + z-index: $euiZLevel3; + } } .lnsDragDrop-translatableDrag { @@ -118,10 +139,6 @@ // Draggable item when it is moving .lnsDragDrop-isHidden { opacity: 0; -} - -.lnsDragDrop-isHidden-noFocus { - opacity: 0; .lnsDragDrop__keyboardHandler { &:focus, &:focus-within { @@ -129,3 +146,60 @@ } } } + +.lnsDragDrop__extraDrops { + opacity: 0; + visibility: hidden; + position: absolute; + z-index: $euiZLevel2; + right: calc(100% + #{$euiSizeS}); + top: 0; + transition: opacity $euiAnimSpeedFast ease-in-out; + width:100%; +} + +.lnsDragDrop__extraDrops-visible { + opacity: 1; + visibility: visible; +} + +.lnsDragDrop__diamondPath { + position: absolute; + width: 30%; + top: 0; + left: -$euiSize; + z-index: $euiZLevel0; +} + +.lnsDragDrop__extraDropWrapper { + position: relative; + width: 100%; + height: 100%; + background: $euiColorLightestShade; + padding: $euiSizeXS; + border-radius: 0; + &:first-child, &:first-child .lnsDragDrop__extraDrop { + border-top-left-radius: $euiSizeXS; + border-top-right-radius: $euiSizeXS; + } + &:last-child, &:last-child .lnsDragDrop__extraDrop { + border-bottom-left-radius: $euiSizeXS; + border-bottom-right-radius: $euiSizeXS; + } +} + +// collapse borders +.lnsDragDrop__extraDropWrapper + .lnsDragDrop__extraDropWrapper { + margin-top: -1px; +} + +.lnsDragDrop__extraDrop { + position: relative; + height: $euiSizeXS * 10; + min-width: $euiSize * 7; + color: $euiColorSuccessText; + padding: $euiSizeXS; + &.lnsDragDrop-incompatibleExtraDrop { + color: $euiColorWarningText; + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index dd1e351b824fe..e582c4318afc3 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, mount } from 'enzyme'; +import { render, mount, ReactWrapper } from 'enzyme'; import { DragDrop } from './drag_drop'; import { ChildDragDropProvider, @@ -39,7 +39,11 @@ describe('DragDrop', () => { registerDropTarget: jest.fn(), }; - const value = { id: '1', humanData: { label: 'hello' } }; + const value = { + id: '1', + humanData: { label: 'hello', groupLabel: 'X', position: 1, canSwap: true, canDuplicate: true }, + }; + test('renders if nothing is being dragged', () => { const component = render( @@ -53,17 +57,17 @@ describe('DragDrop', () => { test('dragover calls preventDefault if dropType is defined', () => { const preventDefault = jest.fn(); const component = mount( - + ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragover', { preventDefault }); expect(preventDefault).toBeCalled(); }); - test('dragover does not call preventDefault if dropType is undefined', () => { + test('dragover does not call preventDefault if dropTypes is undefined', () => { const preventDefault = jest.fn(); const component = mount( @@ -71,7 +75,7 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragover', { preventDefault }); expect(preventDefault).not.toBeCalled(); }); @@ -85,7 +89,7 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('mousedown'); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('mousedown'); expect(global.getSelection).toBeCalled(); expect(removeAllRanges).toBeCalled(); }); @@ -107,9 +111,11 @@ describe('DragDrop', () => { ); - component.find('[data-test-subj="lnsDragDrop"]').simulate('dragstart', { dataTransfer }); + component.find('[data-test-subj="lnsDragDrop"]').at(0).simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); expect(setDragging).toBeCalledWith({ ...value }); @@ -128,15 +134,15 @@ describe('DragDrop', () => { dragging={{ id: '2', humanData: { label: 'Label1' } }} setDragging={setDragging} > - + ); - component - .find('[data-test-subj="lnsDragDrop"]') - .simulate('drop', { preventDefault, stopPropagation }); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(0); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); @@ -144,7 +150,7 @@ describe('DragDrop', () => { expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'Label1' } }, 'field_add'); }); - test('drop function is not called on dropType undefined', async () => { + test('drop function is not called on dropTypes undefined', async () => { const preventDefault = jest.fn(); const stopPropagation = jest.fn(); const setDragging = jest.fn(); @@ -156,29 +162,29 @@ describe('DragDrop', () => { dragging={{ id: 'hi', humanData: { label: 'Label1' } }} setDragging={setDragging} > - + ); - component - .find('[data-test-subj="lnsDragDrop"]') - .simulate('drop', { preventDefault, stopPropagation }); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(0); + dragDrop.simulate('dragover'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); - expect(preventDefault).toBeCalled(); - expect(stopPropagation).toBeCalled(); - expect(setDragging).toBeCalledWith(undefined); + expect(preventDefault).not.toHaveBeenCalled(); + expect(stopPropagation).not.toHaveBeenCalled(); + expect(setDragging).not.toHaveBeenCalled(); expect(onDrop).not.toHaveBeenCalled(); }); - test('defined dropType is reflected in the className', () => { + test('defined dropTypes is reflected in the className', () => { const component = render( { throw x; }} - dropType="field_add" + dropTypes={['field_add']} value={value} order={[2, 0, 1, 0]} > @@ -189,7 +195,7 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); - test('items that has dropType=undefined get special styling when another item is dragged', () => { + test('items that has dropTypes=undefined get special styling when another item is dragged', () => { const component = mount( @@ -198,7 +204,7 @@ describe('DragDrop', () => { {}} - dropType={undefined} + dropTypes={undefined} value={{ id: '2', humanData: { label: 'label2' } }} > @@ -235,7 +241,7 @@ describe('DragDrop', () => { order={[2, 0, 1, 0]} value={value} onDrop={(x: unknown) => {}} - dropType="field_add" + dropTypes={['field_add']} getAdditionalClassesOnEnter={getAdditionalClassesOnEnter} getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -248,11 +254,14 @@ describe('DragDrop', () => { .find('[data-test-subj="lnsDragDrop"]') .first() .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(setA11yMessage).toBeCalledWith('Lifted ignored'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); + const dragDrop = component.find('[data-test-subj="lnsDragDrop"]').at(1); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop'); expect(component.find('.additional')).toHaveLength(0); }); @@ -287,7 +296,7 @@ describe('DragDrop', () => { order={[2, 0, 1, 0]} value={value} onDrop={(x: unknown) => {}} - dropType="field_add" + dropTypes={['field_add']} getAdditionalClassesOnEnter={getAdditionalClasses} getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} > @@ -300,219 +309,652 @@ describe('DragDrop', () => { .find('[data-test-subj="lnsDragDrop"]') .first() .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - expect(component.find('.additional')).toHaveLength(1); + expect(component.find('.additional')).toHaveLength(2); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); expect(setActiveDropTarget).toBeCalledWith(undefined); }); - test('Keyboard navigation: User receives proper drop Targets highlighted when pressing arrow keys', () => { - const onDrop = jest.fn(); - const setActiveDropTarget = jest.fn(); - const setA11yMessage = jest.fn(); - const items = [ - { - draggable: true, - value: { - id: '1', - humanData: { label: 'Label1', position: 1 }, + describe('Keyboard navigation', () => { + test('User receives proper drop Targets highlighted when pressing arrow keys', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], }, - children: '1', - order: [2, 0, 0, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', + { + draggable: true, + dragType: 'move' as 'copy' | 'move', - value: { - id: '2', + value: { + id: '2', - humanData: { label: 'label2', position: 1 }, - }, - onDrop, - dropType: 'move_compatible' as DropType, - order: [2, 0, 1, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', - value: { - id: '3', - humanData: { label: 'label3', position: 1, groupLabel: 'Y' }, + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible'] as DropType[], + order: [2, 0, 1, 0], }, - onDrop, - dropType: 'replace_compatible' as DropType, - order: [2, 0, 2, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', - value: { - id: '4', - humanData: { label: 'label4', position: 2, groupLabel: 'Y' }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '3', + humanData: { + label: 'label3', + position: 1, + groupLabel: 'Y', + canSwap: true, + canDuplicate: true, + }, + }, + onDrop, + dropTypes: [ + 'replace_compatible', + 'duplicate_compatible', + 'swap_compatible', + ] as DropType[], + order: [2, 0, 2, 0], }, - order: [2, 0, 2, 1], - }, - ]; - const component = mount( - , style: {} } }, - setActiveDropTarget, - setA11yMessage, - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, - '2,0,2,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + value: { + id: '4', + humanData: { label: 'label4', position: 2, groupLabel: 'Y' }, }, - keyboardMode: true, - }} - > - {items.map((props) => ( - -
- - ))} - - ); - const keyboardHandler = component - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') - .first() - .simulate('focus'); - act(() => { + order: [2, 0, 2, 1], + }, + ]; + const component = mount( + , style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + '2,0,2,0,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' }, + '2,0,1,0,1': { ...items[1].value, onDrop, dropType: 'duplicate_compatible' }, + '2,0,1,0,2': { ...items[1].value, onDrop, dropType: 'swap_compatible' }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + keyboardHandler.simulate('keydown', { key: 'ArrowRight' }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropTypes![0], + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + `You're dragging Label1 from at position 1 over label3 from Y group at position 1. Press space or enter to replace label3 with Label1. Hold alt or option to duplicate. Hold shift to swap.` + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'Label1', position: 1 }, id: '1' }, + 'move_compatible' + ); }); - expect(setActiveDropTarget).toBeCalledWith({ - ...items[2].value, - onDrop, - dropType: items[2].dropType, + + test('dragstart sets dragging in the context and calls it with proper params', async () => { + const setDragging = jest.fn(); + + const setA11yMessage = jest.fn(); + const component = mount( + + + + + + ); + + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + + keyboardHandler.simulate('keydown', { key: 'Enter' }); + act(() => { + jest.runAllTimers(); + }); + + expect(setDragging).toBeCalledWith({ + ...value, + ghost: { + children: , + style: { + height: 0, + width: 0, + }, + }, + }); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); - keyboardHandler.simulate('keydown', { key: 'Enter' }); - expect(setA11yMessage).toBeCalledWith( - 'Replace label3 in Y group at position 1 with Label1. Press space or enter to replace' - ); - expect(setActiveDropTarget).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith( - { humanData: { label: 'Label1', position: 1 }, id: '1' }, - 'move_compatible' - ); - }); - test('Keyboard navigation: dragstart sets dragging in the context and calls it with proper params', async () => { - const setDragging = jest.fn(); + test('ActiveDropTarget gets ghost image', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', - const setA11yMessage = jest.fn(); - const component = mount( - - - - - - ); + value: { + id: '2', - const keyboardHandler = component - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') - .first() - .simulate('focus'); - - keyboardHandler.simulate('keydown', { key: 'Enter' }); - jest.runAllTimers(); - - expect(setDragging).toBeCalledWith({ - ...value, - ghost: { - children: , - style: { - height: 0, - width: 0, + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible'] as DropType[], + order: [2, 0, 1, 0], }, - }, + ]; + const component = mount( + Hello
, style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + + expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); }); - expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); - test('Keyboard navigation: ActiveDropTarget gets ghost image', () => { + describe('multiple drop targets', () => { + let activeDropTarget: DragContextState['activeDropTarget']; const onDrop = jest.fn(); - const setActiveDropTarget = jest.fn(); + let setActiveDropTarget = jest.fn(); const setA11yMessage = jest.fn(); - const items = [ - { - draggable: true, - value: { - id: '1', - humanData: { label: 'Label1', position: 1 }, - }, - children: '1', - order: [2, 0, 0, 0], - }, - { - draggable: true, - dragType: 'move' as 'copy' | 'move', + let component: ReactWrapper; + beforeEach(() => { + activeDropTarget = undefined; + setActiveDropTarget = jest.fn((val) => { + activeDropTarget = value as DragContextState['activeDropTarget']; + }); + component = mount( + true} + dropTargetsByOrder={undefined} + registerDropTarget={jest.fn()} + > + + + +
{dropType}
} + > + +
+
+ ); + }); + test('extra drop targets render correctly', () => { + expect(component.find('.extraDrop').hostNodes()).toHaveLength(2); + }); - value: { - id: '2', + test('extra drop targets appear when dragging over and disappear when activeDropTarget changes', () => { + component.find('[data-test-subj="lnsDragDropContainer"]').first().simulate('dragenter'); - humanData: { label: 'label2', position: 1 }, - }, + // customDropTargets are visible + expect(component.find('[data-test-subj="lnsDragDropContainer"]').prop('className')).toEqual( + 'lnsDragDrop__container lnsDragDrop__container-active' + ); + expect( + component.find('[data-test-subj="lnsDragDropExtraDrops"]').first().prop('className') + ).toEqual('lnsDragDrop__extraDrops lnsDragDrop__extraDrops-visible'); + + // set activeDropTarget as undefined + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + act(() => { + jest.runAllTimers(); + }); + component.update(); + + // customDropTargets are invisible + expect( + component.find('[data-test-subj="lnsDragDropExtraDrops"]').first().prop('className') + ).toEqual('lnsDragDrop__extraDrops'); + }); + + test('dragging over different drop types of the same value assigns correct activeDropTarget', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + component.find('SingleDropInner').at(0).simulate('dragover'); + + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'move_compatible', onDrop, - dropType: 'move_compatible' as DropType, - order: [2, 0, 1, 0], - }, - ]; - const component = mount( - Hello
, style: {} } }, - setActiveDropTarget, - setA11yMessage, - activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, - dropTargetsByOrder: { - '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }); + + component.find('SingleDropInner').at(1).simulate('dragover'); + + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'duplicate_compatible', + onDrop, + }); + + component.find('SingleDropInner').at(2).simulate('dragover'); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'swap_compatible', + onDrop, + }); + component.find('SingleDropInner').at(2).simulate('dragleave'); + expect(setActiveDropTarget).toBeCalledWith(undefined); + }); + + test('drop on extra drop target passes correct dropType to onDrop', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + component.find('SingleDropInner').at(0).simulate('dragover'); + component.find('SingleDropInner').at(0).simulate('drop'); + expect(onDrop).toBeCalledWith({ humanData: { label: 'Label1' }, id: '1' }, 'move_compatible'); + + component.find('SingleDropInner').at(1).simulate('dragover'); + component.find('SingleDropInner').at(1).simulate('drop'); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'Label1' }, id: '1' }, + 'duplicate_compatible' + ); + + component.find('SingleDropInner').at(2).simulate('dragover'); + component.find('SingleDropInner').at(2).simulate('drop'); + expect(onDrop).toBeCalledWith({ humanData: { label: 'Label1' }, id: '1' }, 'swap_compatible'); + }); + + test('pressing Alt or Shift when dragging over the main drop target sets extra drop target as active', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + // needed to setup activeDropType + component + .find('SingleDropInner') + .at(0) + .simulate('dragover', { altKey: true }) + .simulate('dragover', { altKey: true }); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'duplicate_compatible', + onDrop, + }); + + component + .find('SingleDropInner') + .at(0) + .simulate('dragover', { shiftKey: true }) + .simulate('dragover', { shiftKey: true }); + expect(setActiveDropTarget).toBeCalledWith({ + ...value, + dropType: 'swap_compatible', + onDrop, + }); + }); + + test('pressing Alt or Shift when dragging over the extra drop target does nothing', () => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + + const extraDrop = component.find('SingleDropInner').at(1); + extraDrop.simulate('dragover', { altKey: true }); + extraDrop.simulate('dragover', { shiftKey: true }); + extraDrop.simulate('dragover'); + expect( + setActiveDropTarget.mock.calls.every((call) => call[0].dropType === 'duplicate_compatible') + ); + }); + describe('keyboard navigation', () => { + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, }, - keyboardMode: true, - }} - > - {items.map((props) => ( - -
- - ))} - - ); + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as const, - expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'] as DropType[], + order: [2, 0, 1, 0], + }, + { + draggable: true, + dragType: 'move' as const, + value: { + id: '3', + humanData: { label: 'label3', position: 1, groupLabel: 'Y' }, + }, + onDrop, + dropTypes: ['replace_compatible'] as DropType[], + order: [2, 0, 2, 0], + }, + ]; + const assignedDropTargetsByOrder: DragContextState['dropTargetsByOrder'] = { + '2,0,1,0,0': { + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }, + '2,0,1,0,1': { + dropType: 'duplicate_compatible', + humanData: { + label: 'label2', + position: 1, + }, + id: '2', + onDrop, + }, + '2,0,1,0,2': { + dropType: 'swap_compatible', + humanData: { + label: 'label2', + position: 1, + }, + id: '2', + onDrop, + }, + '2,0,2,0,0': { + dropType: 'replace_compatible', + humanData: { + groupLabel: 'Y', + label: 'label3', + position: 1, + }, + id: '3', + onDrop, + }, + }; + test('when pressing enter key, context receives the proper dropTargetsByOrder', () => { + let dropTargetsByOrder: DragContextState['dropTargetsByOrder'] = {}; + const setKeyboardMode = jest.fn(); + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget, + dropTargetsByOrder, + keyboardMode: true, + setKeyboardMode, + registerDropTarget: jest.fn((order, dropTarget) => { + dropTargetsByOrder = { + ...dropTargetsByOrder, + [order.join(',')]: dropTarget, + }; + }), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]').first().simulate('focus'); + act(() => { + jest.runAllTimers(); + }); + component.update(); + expect(dropTargetsByOrder).toEqual(assignedDropTargetsByOrder); + }); + test('when pressing ArrowRight key with modifier key pressed in, the extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: undefined, + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'ArrowRight', altKey: true }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'duplicate_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'ArrowRight', shiftKey: true }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'swap_compatible', + }); + }); + test('when having a main target selected and pressing alt, the first extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: assignedDropTargetsByOrder['2,0,1,0,0'], + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'Alt' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'duplicate_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keyup', { key: 'Alt' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }); + }); + test('when having a main target selected and pressing shift, the second extra drop target is selected', () => { + component = mount( + , style: {} } }, + setDragging: jest.fn(), + setActiveDropTarget, + setA11yMessage, + activeDropTarget: assignedDropTargetsByOrder['2,0,1,0,0'], + dropTargetsByOrder: assignedDropTargetsByOrder, + keyboardMode: true, + setKeyboardMode: jest.fn(), + registerDropTarget: jest.fn(), + }} + > + {items.map((props) => ( + +
+ + ))} + + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keydown', { key: 'Shift' }); + }); + + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'swap_compatible', + }); + act(() => { + component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('keyup', { key: 'Shift' }); + }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[1].value, + onDrop, + dropType: 'move_compatible', + }); + }); + }); }); - describe('reordering', () => { + describe('Reordering', () => { const onDrop = jest.fn(); const items = [ { id: '1', humanData: { label: 'Label1', position: 1, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, + draggable: true, }, { id: '2', humanData: { label: 'label2', position: 2, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, }, { id: '3', humanData: { label: 'label3', position: 3, groupLabel: 'X' }, onDrop, - dropType: 'reorder' as DropType, }, ]; const mountComponent = ( @@ -546,7 +988,6 @@ describe('DragDrop', () => { const dragDropSharedProps = { draggable: true, dragType: 'move' as 'copy' | 'move', - dropType: 'reorder' as DropType, reorderableGroup: items.map(({ id }) => ({ id })), onDrop: onDropHandler || onDrop, }; @@ -557,15 +998,25 @@ describe('DragDrop', () => { 1 - + 2 - + 3 @@ -574,7 +1025,10 @@ describe('DragDrop', () => { }; test(`Inactive group renders properly`, () => { const component = mountComponent(undefined); - expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(3); + act(() => { + jest.runAllTimers(); + }); + expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(5); }); test(`Reorderable group with lifted element renders properly`, () => { @@ -585,31 +1039,32 @@ describe('DragDrop', () => { setDragging, setA11yMessage, }); + + act(() => { + jest.runAllTimers(); + }); + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + act(() => { - component - .find('[data-test-subj="lnsDragDrop"]') - .first() - .simulate('dragstart', { dataTransfer }); jest.runAllTimers(); }); expect(setDragging).toBeCalledWith({ ...items[0] }); expect(setA11yMessage).toBeCalledWith('Lifted Label1'); - expect( - component - .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') - .hasClass('lnsDragDrop-isActiveGroup') - ).toEqual(true); }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { const component = mountComponent({ dragging: { ...items[0] } }); + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + act(() => { - component - .find('[data-test-subj="lnsDragDrop"]') - .first() - .simulate('dragstart', { dataTransfer }); jest.runAllTimers(); }); @@ -656,14 +1111,16 @@ describe('DragDrop', () => { setA11yMessage, }); - component - .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') - .at(1) - .simulate('drop', { preventDefault, stopPropagation }); - jest.runAllTimers(); + const dragDrop = component.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]').at(1); + dragDrop.simulate('dragOver'); + dragDrop.simulate('drop', { preventDefault, stopPropagation }); + + act(() => { + jest.runAllTimers(); + }); expect(setA11yMessage).toBeCalledWith( - 'Reordered Label1 in X group from position 1 to positon 3' + 'Reordered Label1 in X group from position 1 to position 3' ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); @@ -685,7 +1142,9 @@ describe('DragDrop', () => { setActiveDropTarget, setA11yMessage, }); - const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first(); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); @@ -694,11 +1153,12 @@ describe('DragDrop', () => { keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(setActiveDropTarget).toBeCalledWith(items[1]); + expect(setActiveDropTarget).toBeCalledWith({ ...items[1], dropType: 'reorder' }); expect(setA11yMessage).toBeCalledWith( 'Reorder Label1 in X group from position 1 to position 2. Press space or enter to reorder' ); }); + test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ dragging: { ...items[0] }, @@ -712,6 +1172,7 @@ describe('DragDrop', () => { }); const keyboardHandler = component .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() .simulate('focus'); act(() => { @@ -732,7 +1193,9 @@ describe('DragDrop', () => { const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'Escape' }); - jest.runAllTimers(); + act(() => { + jest.runAllTimers(); + }); expect(onDropHandler).not.toHaveBeenCalled(); expect(setA11yMessage).toBeCalledWith( @@ -828,7 +1291,7 @@ describe('DragDrop', () => { ; +const noop = () => {}; + /** * The base props to the DragDrop component. */ @@ -53,7 +57,7 @@ interface BaseProps { /** * The React element which will be passed the draggable handlers */ - children: React.ReactElement; + children: ReactElement; /** * Indicates whether or not this component is draggable. */ @@ -85,14 +89,18 @@ interface BaseProps { dragType?: 'copy' | 'move'; /** - * Indicates the type of a drop - when undefined, the currently dragged item + * Indicates the type of drop targets - when undefined, the currently dragged item * cannot be dropped onto this component. */ - dropType?: DropType; + dropTypes?: DropType[]; /** * Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically */ order: number[]; + /** + * Extra drop targets by dropType + */ + getCustomDropTarget?: (dropType: DropType) => ReactElement | null; } /** @@ -109,19 +117,17 @@ interface DragInnerProps extends BaseProps { dropTargetsByOrder: DragContextState['dropTargetsByOrder']; }; onDragStart?: ( - target?: - | DroppableEvent['currentTarget'] - | React.KeyboardEvent['currentTarget'] + target?: DroppableEvent['currentTarget'] | KeyboardEvent['currentTarget'] ) => void; onDragEnd?: () => void; - extraKeyboardHandler?: (e: React.KeyboardEvent) => void; + extraKeyboardHandler?: (e: KeyboardEvent) => void; ariaDescribedBy?: string; } /** * The props for a non-draggable instance of that component. */ -interface DropInnerProps extends BaseProps { +interface DropsInnerProps extends BaseProps { dragging: DragContextState['dragging']; keyboardMode: DragContextState['keyboardMode']; setKeyboardMode: DragContextState['setKeyboardMode']; @@ -129,7 +135,7 @@ interface DropInnerProps extends BaseProps { setActiveDropTarget: DragContextState['setActiveDropTarget']; setA11yMessage: DragContextState['setA11yMessage']; registerDropTarget: DragContextState['registerDropTarget']; - isActiveDropTarget: boolean; + activeDropTarget: DragContextState['activeDropTarget']; isNotDroppable: boolean; } @@ -148,7 +154,7 @@ export const DragDrop = (props: BaseProps) => { setA11yMessage, } = useContext(DragContext); - const { value, draggable, dropType, reorderableGroup } = props; + const { value, draggable, dropTypes, reorderableGroup } = props; const isDragging = !!(draggable && value.id === dragging?.id); const activeDraggingProps = isDragging @@ -159,7 +165,7 @@ export const DragDrop = (props: BaseProps) => { } : undefined; - if (draggable && !dropType) { + if (draggable && (!dropTypes || !dropTypes.length)) { const dragProps = { ...props, activeDraggingProps, @@ -175,14 +181,13 @@ export const DragDrop = (props: BaseProps) => { } } - const isActiveDropTarget = Boolean(activeDropTarget?.id === value.id); const dropProps = { ...props, keyboardMode, setKeyboardMode, dragging, setDragging, - isActiveDropTarget, + activeDropTarget, setActiveDropTarget, registerDropTarget, setA11yMessage, @@ -190,19 +195,20 @@ export const DragDrop = (props: BaseProps) => { // If the configuration has provided a droppable flag, but this particular item is not // droppable, then it should be less prominent. Ignores items that are both // draggable and drop targets - !!(!dropType && dragging && value.id !== dragging.id), + !!((!dropTypes || !dropTypes.length) && dragging && value.id !== dragging.id), }; if ( reorderableGroup && reorderableGroup.length > 1 && - reorderableGroup?.some((i) => i.id === dragging?.id) + reorderableGroup?.some((i) => i.id === dragging?.id) && + dropTypes?.[0] === 'reorder' ) { return ; } - return ; + return ; }; -const removeSelectionBeforeDragging = () => { +const removeSelection = () => { const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); @@ -230,8 +236,60 @@ const DragInner = memo(function DragInner({ const activeDropTarget = activeDraggingProps?.activeDropTarget; const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder; + const setTarget = useCallback( + (target?: DropIdentifier, announceModifierKeys = false) => { + setActiveDropTarget(target); + setA11yMessage( + target + ? announce.selectedTarget( + value.humanData, + target?.humanData, + target?.dropType, + announceModifierKeys + ) + : announce.noTarget() + ); + }, + [setActiveDropTarget, setA11yMessage, value.humanData] + ); + + const setTargetOfIndex = useCallback( + (id: string, index: number) => { + const dropTargetsForActiveId = + dropTargetsByOrder && + Object.values(dropTargetsByOrder).filter((dropTarget) => dropTarget?.id === id); + if (index > 0 && dropTargetsForActiveId?.[index]) { + setTarget(dropTargetsForActiveId[index]); + } else { + setTarget(dropTargetsForActiveId?.[0], true); + } + }, + [dropTargetsByOrder, setTarget] + ); + const modifierHandlers = useMemo(() => { + const onKeyUp = (e: KeyboardEvent) => { + if ((e.key === 'Shift' || e.key === 'Alt') && activeDropTarget?.id) { + if (e.altKey) { + setTargetOfIndex(activeDropTarget.id, 1); + } else if (e.shiftKey) { + setTargetOfIndex(activeDropTarget.id, 2); + } else { + setTargetOfIndex(activeDropTarget.id, 0); + } + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt' && activeDropTarget?.id) { + setTargetOfIndex(activeDropTarget.id, 1); + } else if (e.key === 'Shift' && activeDropTarget?.id) { + setTargetOfIndex(activeDropTarget.id, 2); + } + }; + return { onKeyDown, onKeyUp }; + }, [activeDropTarget, setTargetOfIndex]); + const dragStart = ( - e: DroppableEvent | React.KeyboardEvent, + e: DroppableEvent | KeyboardEvent, keyboardModeOn?: boolean ) => { // Setting stopPropgagation causes Chrome failures, so @@ -282,20 +340,8 @@ const DragInner = memo(function DragInner({ onDragEnd(); } }; - const dropToActiveDropTarget = () => { - if (activeDropTarget) { - trackUiEvent('drop_total'); - const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget; - setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); - onTargetDrop(value, dropType); - } - }; - - const setNextTarget = (reversed = false) => { - if (!order) { - return; - } + const setNextTarget = (e: KeyboardEvent, reversed = false) => { const nextTarget = nextValidDropTarget( dropTargetsByOrder, activeDropTarget, @@ -304,13 +350,24 @@ const DragInner = memo(function DragInner({ reversed ); - setActiveDropTarget(nextTarget); - setA11yMessage( - nextTarget - ? announce.selectedTarget(value.humanData, nextTarget?.humanData, nextTarget?.dropType) - : announce.noTarget() - ); + if (e.altKey && nextTarget?.id) { + setTargetOfIndex(nextTarget.id, 1); + } else if (e.shiftKey && nextTarget?.id) { + setTargetOfIndex(nextTarget.id, 2); + } else { + setTarget(nextTarget, true); + } }; + + const dropToActiveDropTarget = () => { + if (activeDropTarget) { + trackUiEvent('drop_total'); + const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget; + setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType))); + onTargetDrop(value, dropType); + } + }; + const shouldShowGhostImageInstead = dragType === 'move' && keyboardMode && @@ -319,7 +376,9 @@ const DragInner = memo(function DragInner({ return (
@@ -334,7 +393,7 @@ const DragInner = memo(function DragInner({ dragEnd(); } }} - onKeyDown={(e: React.KeyboardEvent) => { + onKeyDown={(e: KeyboardEvent) => { const { key } = e; if (key === keys.ENTER || key === keys.SPACE) { if (activeDropTarget) { @@ -356,30 +415,30 @@ const DragInner = memo(function DragInner({ if (extraKeyboardHandler) { extraKeyboardHandler(e); } - if (keyboardMode && (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key)) { - setNextTarget(!!(keys.ARROW_LEFT === key)); + if (keyboardMode) { + if (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key) { + setNextTarget(e, !!(keys.ARROW_LEFT === key)); + } + modifierHandlers.onKeyDown(e); } }} + onKeyUp={modifierHandlers.onKeyUp} /> {React.cloneElement(children, { 'data-test-subj': dataTestSubj || 'lnsDragDrop', - className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable', { - 'lnsDragDrop-isHidden': - (activeDraggingProps && dragType === 'move' && !keyboardMode) || - shouldShowGhostImageInstead, - }), + className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable'), draggable: true, onDragEnd: dragEnd, onDragStart: dragStart, - onMouseDown: removeSelectionBeforeDragging, + onMouseDown: removeSelection, })}
); }); -const DropInner = memo(function DropInner(props: DropInnerProps) { +const DropsInner = memo(function DropsInner(props: DropsInnerProps) { const { dataTestSubj, className, @@ -389,54 +448,86 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { draggable, dragging, isNotDroppable, - dropType, + dropTypes, order, getAdditionalClassesOnEnter, getAdditionalClassesOnDroppable, - isActiveDropTarget, + activeDropTarget, registerDropTarget, setActiveDropTarget, keyboardMode, setKeyboardMode, setDragging, setA11yMessage, + getCustomDropTarget, } = props; + const [isInZone, setIsInZone] = useState(false); + const mainTargetRef = useRef(null); + useShallowCompareEffect(() => { - if (dropType && onDrop && keyboardMode) { - registerDropTarget(order, { ...value, onDrop, dropType }); + if (dropTypes && dropTypes?.[0] && onDrop && keyboardMode) { + dropTypes.forEach((dropType, index) => { + registerDropTarget([...order, index], { ...value, onDrop, dropType }); + }); return () => { - registerDropTarget(order, undefined); + dropTypes.forEach((_, index) => { + registerDropTarget([...order, index], undefined); + }); }; } - }, [order, value, registerDropTarget, dropType, keyboardMode]); - - const classesOnEnter = getAdditionalClassesOnEnter?.(dropType); - const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); - - const classes = classNames( - 'lnsDragDrop', - { - 'lnsDragDrop-isDraggable': draggable, - 'lnsDragDrop-isDroppable': !draggable, - 'lnsDragDrop-isDropTarget': dropType && dropType !== 'reorder', - 'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget && dropType !== 'reorder', - 'lnsDragDrop-isNotDroppable': isNotDroppable, - }, - classesOnEnter && { [classesOnEnter]: isActiveDropTarget }, - classesOnDroppable && { [classesOnDroppable]: dropType } - ); + }, [order, registerDropTarget, dropTypes, keyboardMode]); - const dragOver = (e: DroppableEvent) => { - if (!dropType) { - return; + useEffect(() => { + if (activeDropTarget && activeDropTarget.id !== value.id) { + setIsInZone(false); } + setTimeout(() => { + if (!activeDropTarget) { + setIsInZone(false); + } + }, 1000); + }, [activeDropTarget, setIsInZone, value.id]); + + const dragEnter = () => { + if (!isInZone) { + setIsInZone(true); + } + }; + + const getModifiedDropType = (e: DroppableEvent, dropType: DropType) => { + if (!dropTypes || dropTypes.length <= 1) { + return dropType; + } + const dropIndex = dropTypes.indexOf(dropType); + if (dropIndex > 0) { + return dropType; + } else if (dropIndex === 0) { + if (e.altKey && dropTypes[1]) { + return dropTypes[1]; + } else if (e.shiftKey && dropTypes[2]) { + return dropTypes[2]; + } + } + return dropType; + }; + + const dragOver = (e: DroppableEvent, dropType: DropType) => { e.preventDefault(); + if (!dragging || !onDrop) { + return; + } + const modifiedDropType = getModifiedDropType(e, dropType); + const isActiveDropTarget = !!( + activeDropTarget?.id === value.id && activeDropTarget?.dropType === modifiedDropType + ); // An optimization to prevent a bunch of React churn. - if (!isActiveDropTarget && dragging && onDrop) { - setActiveDropTarget({ ...value, dropType, onDrop }); - setA11yMessage(announce.selectedTarget(dragging.humanData, value.humanData, dropType)); + if (!isActiveDropTarget) { + setActiveDropTarget({ ...value, dropType: modifiedDropType, onDrop }); + setA11yMessage( + announce.selectedTarget(dragging.humanData, value.humanData, modifiedDropType) + ); } }; @@ -444,35 +535,146 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { setActiveDropTarget(undefined); }; - const drop = (e: DroppableEvent | React.KeyboardEvent) => { + const drop = (e: DroppableEvent, dropType: DropType) => { e.preventDefault(); e.stopPropagation(); - - if (onDrop && dropType && dragging) { - trackUiEvent('drop_total'); - onDrop(dragging, dropType); + setIsInZone(false); + if (onDrop && dragging) { + const modifiedDropType = getModifiedDropType(e, dropType); + onDrop(dragging, modifiedDropType); setTimeout(() => - setA11yMessage(announce.dropped(dragging.humanData, value.humanData, dropType)) + setA11yMessage(announce.dropped(dragging.humanData, value.humanData, modifiedDropType)) ); } + setDragging(undefined); setActiveDropTarget(undefined); setKeyboardMode(false); }; - const ghost = - isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined; + const getProps = (dropType?: DropType, dropChildren?: ReactElement) => { + const isActiveDropTarget = Boolean( + activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType + ); + return { + 'data-test-subj': dataTestSubj || 'lnsDragDrop', + className: getClasses(dropType, dropChildren), + onDragEnter: dragEnter, + onDragLeave: dragLeave, + onDragOver: dropType ? (e: DroppableEvent) => dragOver(e, dropType) : noop, + onDrop: dropType ? (e: DroppableEvent) => drop(e, dropType) : noop, + draggable, + ghost: + (isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost && dragging.ghost) || + undefined, + }; + }; + + const getClasses = (dropType?: DropType, dropChildren = children) => { + const isActiveDropTarget = Boolean( + activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType + ); + const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType); + + const classes = classNames( + 'lnsDragDrop', + { + 'lnsDragDrop-isDraggable': draggable, + 'lnsDragDrop-isDroppable': !draggable, + 'lnsDragDrop-isDropTarget': dropType, + 'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget, + 'lnsDragDrop-isNotDroppable': isNotDroppable, + }, + classesOnDroppable && { [classesOnDroppable]: dropType } + ); + return classNames(classes, className, dropChildren.props.className); + }; + + const getMainTargetClasses = () => { + const classesOnEnter = getAdditionalClassesOnEnter?.(activeDropTarget?.dropType); + return classNames(classesOnEnter && { [classesOnEnter]: activeDropTarget?.id === value.id }); + }; + + const mainTargetProps = getProps(dropTypes && dropTypes[0]); + + const extraDropStyles = useMemo(() => { + const extraDrops = dropTypes && dropTypes.length && dropTypes.slice(1); + if (!extraDrops || !extraDrops.length) { + return; + } + + const height = extraDrops.length * 40; + const minHeight = height - (mainTargetRef.current?.clientHeight || 40); + const clipPath = `polygon(100% 0px, 100% ${height - minHeight}px, 0 100%, 0 0)`; + return { + clipPath, + height, + }; + }, [dropTypes]); + + return ( +
+ + {dropTypes && dropTypes.length > 1 && ( + <> +
+ + {dropTypes.slice(1).map((dropType) => { + const dropChildren = getCustomDropTarget?.(dropType); + return dropChildren ? ( + + + {dropChildren} + + + ) : null; + })} + + + )} +
+ ); +}); +const SingleDropInner = ({ + ghost, + children, + ...rest +}: { + ghost?: Ghost; + children: ReactElement; + style?: React.CSSProperties; + className?: string; +}) => { return ( <> - {React.cloneElement(children, { - 'data-test-subj': dataTestSubj || 'lnsDragDrop', - className: classNames(children.props.className, classes, className), - onDragOver: dragOver, - onDragLeave: dragLeave, - onDrop: drop, - draggable, - })} + {React.cloneElement(children, rest)} {ghost ? React.cloneElement(ghost.children, { className: classNames(ghost.children.props.className, 'lnsDragDrop_ghost'), @@ -481,7 +683,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) { : null} ); -}); +}; const ReorderableDrag = memo(function ReorderableDrag( props: DragInnerProps & { reorderableGroup: Array<{ id: string }>; dragging?: DragDropIdentifier } @@ -519,7 +721,7 @@ const ReorderableDrag = memo(function ReorderableDrag( const onReorderableDragStart = ( currentTarget?: | DroppableEvent['currentTarget'] - | React.KeyboardEvent['currentTarget'] + | KeyboardEvent['currentTarget'] ) => { if (currentTarget) { const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; @@ -540,7 +742,7 @@ const ReorderableDrag = memo(function ReorderableDrag( reorderedItems: [], })); - const extraKeyboardHandler = (e: React.KeyboardEvent) => { + const extraKeyboardHandler = (e: KeyboardEvent) => { if (isReorderOn && keyboardMode) { e.stopPropagation(); e.preventDefault(); @@ -644,7 +846,7 @@ const ReorderableDrag = memo(function ReorderableDrag( }); const ReorderableDrop = memo(function ReorderableDrop( - props: DropInnerProps & { reorderableGroup: Array<{ id: string }> } + props: DropsInnerProps & { reorderableGroup: Array<{ id: string }> } ) { const { onDrop, @@ -652,11 +854,10 @@ const ReorderableDrop = memo(function ReorderableDrop( dragging, setDragging, setKeyboardMode, - isActiveDropTarget, + activeDropTarget, setActiveDropTarget, reorderableGroup, setA11yMessage, - dropType, } = props; const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); @@ -666,7 +867,7 @@ const ReorderableDrop = memo(function ReorderableDrop( setReorderState, } = useContext(ReorderContext); - const heightRef = React.useRef(null); + const heightRef = useRef(null); const isReordered = isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length; @@ -688,42 +889,38 @@ const ReorderableDrop = memo(function ReorderableDrop( }, [isReordered, setReorderState, value.id]); const onReorderableDragOver = (e: DroppableEvent) => { - if (!dropType) { - return; - } e.preventDefault(); - // An optimization to prevent a bunch of React churn. - if (!isActiveDropTarget && dropType && onDrop) { - setActiveDropTarget({ ...value, dropType, onDrop }); - } + if (activeDropTarget?.id !== value?.id && onDrop) { + setActiveDropTarget({ ...value, dropType: 'reorder', onDrop }); - const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); - if (!dragging || draggingIndex === -1) { - return; - } - const droppingIndex = currentIndex; - if (draggingIndex === droppingIndex) { - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - } + if (!dragging || draggingIndex === -1) { + return; + } + const droppingIndex = currentIndex; + if (draggingIndex === droppingIndex) { + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + } - setReorderState((s: ReorderState) => - draggingIndex < droppingIndex - ? { - ...s, - reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), - direction: '-', - } - : { - ...s, - reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), - direction: '+', - } - ); + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { + ...s, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', + } + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + } }; const onReorderableDrop = (e: DroppableEvent) => { @@ -734,7 +931,7 @@ const ReorderableDrop = memo(function ReorderableDrop( setDragging(undefined); setKeyboardMode(false); - if (onDrop && dropType && dragging) { + if (onDrop && dragging) { trackUiEvent('drop_total'); onDrop(dragging, 'reorder'); // setTimeout ensures it will run after dragEnd messaging @@ -758,17 +955,18 @@ const ReorderableDrop = memo(function ReorderableDrop( data-test-subj="lnsDragDrop-translatableDrop" className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable" > - +
{ + setActiveDropTarget(undefined); setReorderState((s: ReorderState) => ({ ...s, reorderedItems: [], diff --git a/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx index 3bd1d5693005c..72771edbae981 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/announcements.tsx @@ -9,132 +9,340 @@ import { i18n } from '@kbn/i18n'; import { DropType } from '../../types'; import { HumanData } from '.'; -type AnnouncementFunction = (draggedElement: HumanData, dropElement: HumanData) => string; +type AnnouncementFunction = ( + draggedElement: HumanData, + dropElement: HumanData, + announceModifierKeys?: boolean +) => string; interface CustomAnnouncementsType { dropped: Partial<{ [dropType in DropType]: AnnouncementFunction }>; selectedTarget: Partial<{ [dropType in DropType]: AnnouncementFunction }>; } -const selectedTargetReplace = ( - { label }: HumanData, - { label: dropLabel, groupLabel, position }: HumanData -) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replace', { - defaultMessage: `Replace {dropLabel} in {groupLabel} group at position {position} with {label}. Press space or enter to replace`, - values: { - label, - dropLabel, - groupLabel, - position, - }, - }); +const replaceAnnouncement = { + selectedTarget: ( + { label, groupLabel, position }: HumanData, + { + label: dropLabel, + groupLabel: dropGroupLabel, + position: dropPosition, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over {dropLabel} from {dropGroupLabel} group at position {dropPosition}. Press space or enter to replace {dropLabel} with {label}.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropLabel, + dropGroupLabel, + dropPosition, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } -const droppedReplace = ( - { label }: HumanData, - { label: dropLabel, groupLabel, position }: HumanData -) => - i18n.translate('xpack.lens.dragDrop.announce.duplicated.replace', { - defaultMessage: 'Replaced {dropLabel} with {label} in {groupLabel} at position {position}', - values: { - label, - dropLabel, - groupLabel, - position, - }, - }); + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replace', { + defaultMessage: `Replace {dropLabel} in {dropGroupLabel} group at position {dropPosition} with {label}. Press space or enter to replace.`, + values: { + label, + dropLabel, + dropGroupLabel, + dropPosition, + }, + }); + }, + dropped: ({ label }: HumanData, { label: dropLabel, groupLabel, position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.duplicated.replace', { + defaultMessage: 'Replaced {dropLabel} with {label} in {groupLabel} at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + }, + }), +}; + +const duplicateAnnouncement = { + selectedTarget: ( + { label, groupLabel }: HumanData, + { groupLabel: dropGroupLabel, position }: HumanData + ) => { + if (groupLabel !== dropGroupLabel) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicated', { + defaultMessage: `Duplicate {label} to {dropGroupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate`, + values: { + label, + dropGroupLabel, + position, + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicatedInGroup', { + defaultMessage: `Duplicate {label} to {dropGroupLabel} group at position {position}. Press space or enter to duplicate`, + values: { + label, + dropGroupLabel, + position, + }, + }); + }, + dropped: ({ label }: HumanData, { groupLabel, position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicated', { + defaultMessage: 'Duplicated {label} in {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + }, + }), +}; + +const reorderAnnouncement = { + selectedTarget: ( + { label, groupLabel, position: prevPosition }: HumanData, + { position }: HumanData + ) => + prevPosition === position + ? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reorderedBack', { + defaultMessage: `{label} returned to its initial position {prevPosition}`, + values: { + label, + prevPosition, + }, + }) + : i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reordered', { + defaultMessage: `Reorder {label} in {groupLabel} group from position {prevPosition} to position {position}. Press space or enter to reorder`, + values: { + groupLabel, + label, + position, + prevPosition, + }, + }), + dropped: ({ label, groupLabel, position: prevPosition }: HumanData, { position }: HumanData) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.reordered', { + defaultMessage: + 'Reordered {label} in {groupLabel} group from position {prevPosition} to position {position}', + values: { + label, + groupLabel, + position, + prevPosition, + }, + }), +}; + +const DUPLICATE_SHORT = i18n.translate('xpack.lens.dragDrop.announce.duplicate.short', { + defaultMessage: ' Hold alt or option to duplicate.', +}); + +const SWAP_SHORT = i18n.translate('xpack.lens.dragDrop.announce.swap.short', { + defaultMessage: ' Hold shift to swap.', +}); export const announcements: CustomAnnouncementsType = { selectedTarget: { - reorder: ({ label, groupLabel, position: prevPosition }, { position }) => - prevPosition === position - ? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reorderedBack', { - defaultMessage: `{label} returned to its initial position {prevPosition}`, + reorder: reorderAnnouncement.selectedTarget, + duplicate_compatible: duplicateAnnouncement.selectedTarget, + field_replace: replaceAnnouncement.selectedTarget, + replace_compatible: replaceAnnouncement.selectedTarget, + replace_incompatible: ( + { label, groupLabel, position }: HumanData, + { + label: dropLabel, + groupLabel: dropGroupLabel, + position: dropPosition, + nextLabel, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate( + 'xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatibleMain', + { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over {dropLabel} from {dropGroupLabel} group at position {dropPosition}. Press space or enter to convert {label} to {nextLabel} and replace {dropLabel}.{duplicateCopy}{swapCopy}`, values: { label, - prevPosition, - }, - }) - : i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reordered', { - defaultMessage: `Reorder {label} in {groupLabel} group from position {prevPosition} to position {position}. Press space or enter to reorder`, - values: { groupLabel, - label, position, - prevPosition, + dropLabel, + dropGroupLabel, + dropPosition, + nextLabel, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', }, - }), - duplicate_in_group: ({ label }, { groupLabel, position }) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicated', { - defaultMessage: `Duplicate {label} to {groupLabel} group at position {position}. Press space or enter to duplicate`, + } + ); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatible', { + defaultMessage: `Convert {label} to {nextLabel} and replace {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Press space or enter to replace`, values: { label, - groupLabel, - position, + nextLabel, + dropLabel, + dropGroupLabel, + dropPosition, }, - }), - field_replace: selectedTargetReplace, - replace_compatible: selectedTargetReplace, - replace_incompatible: ( + }); + }, + move_incompatible: ( + { label, groupLabel, position }: HumanData, + { + groupLabel: dropGroupLabel, + position: dropPosition, + nextLabel, + canSwap, + canDuplicate, + }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatibleMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over position {dropPosition} in {dropGroupLabel} group. Press space or enter to convert {label} to {nextLabel} and move.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropPosition, + nextLabel, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatible', { + defaultMessage: `Convert {label} to {nextLabel} and move to {dropGroupLabel} group at position {dropPosition}. Press space or enter to move`, + values: { + label, + nextLabel, + dropGroupLabel, + dropPosition, + }, + }); + }, + + move_compatible: ( + { label, groupLabel, position }: HumanData, + { groupLabel: dropGroupLabel, position: dropPosition, canSwap, canDuplicate }: HumanData, + announceModifierKeys?: boolean + ) => { + if (announceModifierKeys && (canSwap || canDuplicate)) { + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatibleMain', { + defaultMessage: `You're dragging {label} from {groupLabel} at position {position} over position {dropPosition} in {dropGroupLabel} group. Press space or enter to move.{duplicateCopy}{swapCopy}`, + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropPosition, + duplicateCopy: canDuplicate ? DUPLICATE_SHORT : '', + swapCopy: canSwap ? SWAP_SHORT : '', + }, + }); + } + return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatible', { + defaultMessage: `Move {label} to {dropGroupLabel} group at position {dropPosition}. Press space or enter to move`, + values: { + label, + dropGroupLabel, + dropPosition, + }, + }); + }, + duplicate_incompatible: ( { label }: HumanData, - { label: dropLabel, groupLabel, position, nextLabel }: HumanData + { groupLabel, position, nextLabel }: HumanData ) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceIncompatible', { - defaultMessage: `Convert {label} to {nextLabel} and replace {dropLabel} in {groupLabel} group at position {position}. Press space or enter to replace`, + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicateIncompatible', { + defaultMessage: + 'Convert copy of {label} to {nextLabel} and add to {groupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate', values: { label, - nextLabel, - dropLabel, groupLabel, position, + nextLabel, }, }), - move_incompatible: ( + replace_duplicate_incompatible: ( { label }: HumanData, - { label: groupLabel, position, nextLabel }: HumanData + { label: dropLabel, groupLabel, position, nextLabel }: HumanData ) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveIncompatible', { - defaultMessage: `Convert {label} to {nextLabel} and move to {groupLabel} group at position {position}. Press space or enter to move`, + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateIncompatible', { + defaultMessage: + 'Convert copy of {label} to {nextLabel} and replace {dropLabel} in {groupLabel} group at position {position}. Hold Alt or Option and press space or enter to duplicate and replace', values: { label, - nextLabel, groupLabel, position, + dropLabel, + nextLabel, }, }), - move_compatible: ({ label }: HumanData, { groupLabel, position }: HumanData) => - i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.moveCompatible', { - defaultMessage: `Move {label} to {groupLabel} group at position {position}. Press space or enter to move`, + replace_duplicate_compatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replaceDuplicateCompatible', { + defaultMessage: + 'Duplicate {label} and replace {dropLabel} in {groupLabel} at position {position}. Hold Alt or Option and press space or enter to duplicate and replace', values: { label, + dropLabel, groupLabel, position, }, }), - }, - dropped: { - reorder: ({ label, groupLabel, position: prevPosition }, { position }) => - i18n.translate('xpack.lens.dragDrop.announce.dropped.reordered', { + swap_compatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.swapCompatible', { defaultMessage: - 'Reordered {label} in {groupLabel} group from position {prevPosition} to positon {position}', + 'Swap {label} in {groupLabel} group at position {position} with {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Hold Shift and press space or enter to swap', values: { label, groupLabel, position, - prevPosition, + dropLabel, + dropGroupLabel, + dropPosition, }, }), - duplicate_in_group: ({ label }, { groupLabel, position }) => - i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicated', { - defaultMessage: 'Duplicated {label} in {groupLabel} group at position {position}', + swap_incompatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.swapIncompatible', { + defaultMessage: + 'Convert {label} to {nextLabel} in {groupLabel} group at position {position} and swap with {dropLabel} in {dropGroupLabel} group at position {dropPosition}. Hold Shift and press space or enter to swap', values: { label, groupLabel, position, + dropLabel, + dropGroupLabel, + dropPosition, + nextLabel, }, }), - field_replace: droppedReplace, - replace_compatible: droppedReplace, + }, + dropped: { + reorder: reorderAnnouncement.dropped, + duplicate_compatible: duplicateAnnouncement.dropped, + field_replace: replaceAnnouncement.dropped, + replace_compatible: replaceAnnouncement.dropped, replace_incompatible: ( { label }: HumanData, { label: dropLabel, groupLabel, position, nextLabel }: HumanData @@ -171,6 +379,84 @@ export const announcements: CustomAnnouncementsType = { position, }, }), + + duplicate_incompatible: ( + { label }: HumanData, + { groupLabel, position, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicateIncompatible', { + defaultMessage: + 'Converted copy of {label} to {nextLabel} and added to {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + nextLabel, + }, + }), + + replace_duplicate_incompatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.replaceDuplicateIncompatible', { + defaultMessage: + 'Converted copy of {label} to {nextLabel} and replaced {dropLabel} in {groupLabel} group at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + nextLabel, + }, + }), + replace_duplicate_compatible: ( + { label }: HumanData, + { label: dropLabel, groupLabel, position }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.duplicated.replaceDuplicateCompatible', { + defaultMessage: + 'Replaced {dropLabel} with a copy of {label} in {groupLabel} at position {position}', + values: { + label, + dropLabel, + groupLabel, + position, + }, + }), + swap_compatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.swapCompatible', { + defaultMessage: + 'Moved {label} to {dropGroupLabel} at position {dropPosition} and {dropLabel} to {groupLabel} group at position {position}', + values: { + label, + groupLabel, + position, + dropLabel, + dropGroupLabel, + dropPosition, + }, + }), + swap_incompatible: ( + { label, groupLabel, position }: HumanData, + { label: dropLabel, groupLabel: dropGroupLabel, position: dropPosition, nextLabel }: HumanData + ) => + i18n.translate('xpack.lens.dragDrop.announce.dropped.swapIncompatible', { + defaultMessage: + 'Converted {label} to {nextLabel} in {groupLabel} group at position {position} and swapped with {dropLabel} in {dropGroupLabel} group at position {dropPosition}', + values: { + label, + groupLabel, + position, + dropGroupLabel, + dropLabel, + dropPosition, + nextLabel, + }, + }), }, }; @@ -256,7 +542,13 @@ export const announce = { dropped: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) => (type && announcements.dropped?.[type]?.(draggedElement, dropElement)) || defaultAnnouncements.dropped(draggedElement, dropElement), - selectedTarget: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) => - (type && announcements.selectedTarget?.[type]?.(draggedElement, dropElement)) || + selectedTarget: ( + draggedElement: HumanData, + dropElement: HumanData, + type?: DropType, + announceModifierKeys?: boolean + ) => + (type && + announcements.selectedTarget?.[type]?.(draggedElement, dropElement, announceModifierKeys)) || defaultAnnouncements.selectedTarget(draggedElement, dropElement), }; diff --git a/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx index 2c6b07ea11765..4db19e10ec701 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/providers.tsx @@ -135,11 +135,31 @@ export function nextValidDropTarget( return; } - const filteredTargets = Object.entries(dropTargetsByOrder).filter( - ([, dropTarget]) => dropTarget && filterElements(dropTarget) + const filteredTargets: Array<[string, DropIdentifier | undefined]> = Object.entries( + dropTargetsByOrder + ).filter(([, dropTarget]) => { + return dropTarget && filterElements(dropTarget); + }); + + // filter out secondary targets + const uniqueIdTargets = filteredTargets.reduce( + ( + acc: Array<[string, DropIdentifier | undefined]>, + current: [string, DropIdentifier | undefined] + ) => { + const [, currentDropTarget] = current; + if (!currentDropTarget) { + return acc; + } + if (acc.find(([, target]) => target?.id === currentDropTarget.id)) { + return acc; + } + return [...acc, current]; + }, + [] ); - const nextDropTargets = [...filteredTargets, draggingOrder].sort(([orderA], [orderB]) => { + const nextDropTargets = [...uniqueIdTargets, draggingOrder].sort(([orderA], [orderB]) => { const parsedOrderA = orderA.split(',').map((v) => Number(v)); const parsedOrderB = orderB.split(',').map((v) => Number(v)); diff --git a/x-pack/plugins/lens/public/drag_drop/providers/types.tsx b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx index 11f460a400dcd..8b28affa45596 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers/types.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers/types.tsx @@ -12,6 +12,13 @@ export interface HumanData { groupLabel?: string; position?: number; nextLabel?: string; + canSwap?: boolean; + canDuplicate?: boolean; +} + +export interface Ghost { + children: React.ReactElement; + style: React.CSSProperties; } export type DragDropIdentifier = Record & { @@ -23,10 +30,7 @@ export type DragDropIdentifier = Record & { }; export type DraggingIdentifier = DragDropIdentifier & { - ghost?: { - children: React.ReactElement; - style: React.CSSProperties; - }; + ghost?: Ghost; }; export type DropIdentifier = DragDropIdentifier & { diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index 01cc4c7bc85a5..d7183263c519b 100644 --- a/x-pack/plugins/lens/public/drag_drop/readme.md +++ b/x-pack/plugins/lens/public/drag_drop/readme.md @@ -48,7 +48,7 @@ To enable dragging an item, use `DragDrop` with both a `draggable` and a `value` ## Dropping -To enable dropping, use `DragDrop` with both a `droppable` attribute and an `onDrop` handler attribute. Droppable should only be set to true if there is an item being dragged, and if a drop of the dragged item is supported. +To enable dropping, use `DragDrop` with both a `dropTypes` attribute that should be an array with at least one value and an `onDrop` handler attribute. `dropType` should only be truthy if is an item being dragged, and if a drop of the dragged item is supported. ```js const { dragging } = useContext(DragContext); @@ -56,7 +56,7 @@ const { dragging } = useContext(DragContext); return ( onChange([...items, item])} > {items.map((x) => ( @@ -85,8 +85,7 @@ The children `DragDrop` components must have props defined as in the example: i18n.translate('xpack.lens.configure.editConfig', { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx similarity index 72% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index 8449727a9e79d..212b1794d94ea 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -5,32 +5,20 @@ * 2.0. */ -import React, { useMemo, useCallback, useContext } from 'react'; -import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; - +import React, { useMemo, useCallback, useContext, ReactElement } from 'react'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation, DropType, -} from '../../../types'; -import { LayerDatasourceDropProps } from './types'; - -const getAdditionalClassesOnEnter = (dropType?: string) => { - if ( - dropType === 'field_replace' || - dropType === 'replace_compatible' || - dropType === 'replace_incompatible' - ) { - return 'lnsDragDrop-isReplacing'; - } -}; - -const getAdditionalClassesOnDroppable = (dropType?: string) => { - if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { - return 'lnsDragDrop-notCompatible'; - } -}; +} from '../../../../types'; +import { LayerDatasourceDropProps } from '../types'; +import { + getCustomDropTarget, + getAdditionalClassesOnDroppable, + getAdditionalClassesOnEnter, +} from './drop_targets_utils'; export function DraggableDimensionButton({ layerId, @@ -58,7 +46,7 @@ export function DraggableDimensionButton({ group: VisualizationDimensionGroupConfig; groups: VisualizationDimensionGroupConfig[]; label: string; - children: React.ReactElement; + children: ReactElement; layerDatasource: Datasource; layerDatasourceDropProps: LayerDatasourceDropProps; accessorIndex: number; @@ -76,8 +64,18 @@ export function DraggableDimensionButton({ dimensionGroups: groups, }); - const dropType = dropProps?.dropType; + const dropTypes = dropProps?.dropTypes; const nextLabel = dropProps?.nextLabel; + const canDuplicate = !!( + dropTypes && + (dropTypes.includes('replace_duplicate_incompatible') || + dropTypes.includes('replace_duplicate_compatible')) + ); + + const canSwap = !!( + dropTypes && + (dropTypes.includes('swap_incompatible') || dropTypes.includes('swap_compatible')) + ); const value = useMemo( () => ({ @@ -85,15 +83,28 @@ export function DraggableDimensionButton({ groupId: group.groupId, layerId, id: columnId, - dropType, + filterOperations: group.filterOperations, humanData: { + canSwap, + canDuplicate, label, groupLabel: group.groupLabel, position: accessorIndex + 1, nextLabel: nextLabel || '', }, }), - [columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel, nextLabel] + [ + columnId, + group.groupId, + accessorIndex, + layerId, + label, + group.groupLabel, + nextLabel, + group.filterOperations, + canDuplicate, + canSwap, + ] ); // todo: simplify by id and use drop targets? @@ -110,7 +121,7 @@ export function DraggableDimensionButton({ columnId, ]); - const handleOnDrop = React.useCallback( + const handleOnDrop = useCallback( (droppedItem, selectedDropType) => onDrop(droppedItem, value, selectedDropType), [value, onDrop] ); @@ -122,12 +133,13 @@ export function DraggableDimensionButton({ data-test-subj={group.dataTestSubj} > 1 ? reorderableGroup : undefined} value={value} onDrop={handleOnDrop} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx new file mode 100644 index 0000000000000..85934412dd374 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import classNames from 'classnames'; +import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DropType } from '../../../../types'; + +const getExtraDrop = ({ + type, + isIncompatible, +}: { + type: 'swap' | 'duplicate'; + isIncompatible?: boolean; +}) => { + return ( + + + + + + + + + {type === 'duplicate' + ? i18n.translate('xpack.lens.dragDrop.duplicate', { + defaultMessage: 'Duplicate', + }) + : i18n.translate('xpack.lens.dragDrop.swap', { + defaultMessage: 'Swap', + })} + + + + + + + + {' '} + {type === 'duplicate' + ? i18n.translate('xpack.lens.dragDrop.altOption', { + defaultMessage: 'Alt/Option', + }) + : i18n.translate('xpack.lens.dragDrop.shift', { + defaultMessage: 'Shift', + })} + + + + + ); +}; + +const customDropTargetsMap: Partial<{ [dropType in DropType]: React.ReactElement }> = { + replace_duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }), + duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }), + swap_incompatible: getExtraDrop({ type: 'swap', isIncompatible: true }), + replace_duplicate_compatible: getExtraDrop({ type: 'duplicate' }), + duplicate_compatible: getExtraDrop({ type: 'duplicate' }), + swap_compatible: getExtraDrop({ type: 'swap' }), +}; + +export const getCustomDropTarget = (dropType: DropType) => customDropTargetsMap?.[dropType] || null; + +export const getAdditionalClassesOnEnter = (dropType?: string) => { + if ( + dropType && + [ + 'field_replace', + 'replace_compatible', + 'replace_incompatible', + 'replace_duplicate_compatible', + 'replace_duplicate_incompatible', + ].includes(dropType) + ) { + return 'lnsDragDrop-isReplacing'; + } +}; + +export const getAdditionalClassesOnDroppable = (dropType?: string) => { + if ( + dropType && + [ + 'move_incompatible', + 'replace_incompatible', + 'swap_incompatible', + 'duplicate_incompatible', + 'replace_duplicate_incompatible', + ].includes(dropType) + ) { + return 'lnsDragDrop-notCompatible'; + } +}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx similarity index 84% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index a6ccac1427fbf..cb72b986430d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -9,22 +9,17 @@ import React, { useMemo, useState, useEffect, useContext } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { generateId } from '../../../id_generator'; -import { DragDrop, DragDropIdentifier, DragContext } from '../../../drag_drop'; +import { generateId } from '../../../../id_generator'; +import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; -import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../types'; -import { LayerDatasourceDropProps } from './types'; +import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../../types'; +import { LayerDatasourceDropProps } from '../types'; +import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { defaultMessage: 'Empty dimension', }); -const getAdditionalClassesOnDroppable = (dropType?: string) => { - if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') { - return 'lnsDragDrop-notCompatible'; - } -}; - export function EmptyDimensionButton({ group, groups, @@ -69,24 +64,29 @@ export function EmptyDimensionButton({ dimensionGroups: groups, }); - const dropType = dropProps?.dropType; + const dropTypes = dropProps?.dropTypes; const nextLabel = dropProps?.nextLabel; + const canDuplicate = !!( + dropTypes && + (dropTypes.includes('duplicate_compatible') || dropTypes.includes('duplicate_incompatible')) + ); + const value = useMemo( () => ({ columnId: newColumnId, groupId: group.groupId, layerId, id: newColumnId, - dropType, humanData: { label, groupLabel: group.groupLabel, position: itemIndex + 1, nextLabel: nextLabel || '', + canDuplicate, }, }), - [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel] + [newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel, canDuplicate] ); const handleOnDrop = React.useCallback( @@ -101,7 +101,8 @@ export function EmptyDimensionButton({ value={value} order={[2, layerIndex, groupIndex, itemIndex]} onDrop={handleOnDrop} - dropType={dropType} + dropTypes={dropTypes} + getCustomDropTarget={getCustomDropTarget} >
{ }); mockDatasource.getDropProps.mockReturnValue({ - dropType: 'field_add', + dropTypes: ['field_add'], nextLabel: '', }); @@ -496,7 +496,12 @@ describe('LayerPanel', () => { }) ); - component.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop').first().simulate('drop'); + const dragDropElement = component + .find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop') + .first(); + + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -520,7 +525,7 @@ describe('LayerPanel', () => { }); mockDatasource.getDropProps.mockImplementation(({ columnId }) => - columnId !== 'a' ? { dropType: 'field_replace', nextLabel: '' } : undefined + columnId !== 'a' ? { dropTypes: ['field_replace'], nextLabel: '' } : undefined ); const draggingField = { @@ -548,11 +553,13 @@ describe('LayerPanel', () => { component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType') ).toEqual(undefined); - component + const dragDropElement = component .find('[data-test-subj="lnsGroup"] DragDrop') .first() - .find('.lnsLayerPanel__dimension') - .simulate('drop'); + .find('.lnsLayerPanel__dimension'); + + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); expect(mockDatasource.onDrop).not.toHaveBeenCalled(); }); @@ -582,7 +589,7 @@ describe('LayerPanel', () => { }); mockDatasource.getDropProps.mockReturnValue({ - dropType: 'replace_compatible', + dropTypes: ['replace_compatible'], nextLabel: '', }); @@ -611,7 +618,13 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(0).simulate('drop'); + + const dragDropElement = component + .find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop') + .at(0); + dragDropElement.simulate('dragOver'); + dragDropElement.simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -620,7 +633,14 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(1).simulate('drop'); + + const updatedDragDropElement = component + .find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop') + .at(2); + + updatedDragDropElement.simulate('dragOver'); + updatedDragDropElement.simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -712,12 +732,12 @@ describe('LayerPanel', () => { ); act(() => { - component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group'); + component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible'); }); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', - dropType: 'duplicate_in_group', + dropType: 'duplicate_compatible', droppedItem: draggingOperation, }) ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 21115285b5ce0..cf3c9099d4b0d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -25,9 +25,9 @@ import { trackUiEvent } from '../../../lens_ui_telemetry'; import { LayerPanelProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; import { RemoveLayerButton } from './remove_layer_button'; -import { EmptyDimensionButton } from './empty_dimension_button'; -import { DimensionButton } from './dimension_button'; -import { DraggableDimensionButton } from './draggable_dimension_button'; +import { EmptyDimensionButton } from './buttons/empty_dimension_button'; +import { DimensionButton } from './buttons/dimension_button'; +import { DraggableDimensionButton } from './buttons/draggable_dimension_button'; import { useFocusUpdate } from './use_focus_update'; const initialActiveDimensionState = { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss index a8d8146afebb2..ffc0adf3e33ea 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/frame_layout.scss @@ -28,7 +28,8 @@ // Leave out left padding so the left sidebar's focus states are visible outside of content bounds // This also means needing to add same amount of margin to page content and suggestion items padding: $euiSize $euiSize 0; - + position: relative; + z-index: $euiZLevel1; &:first-child { padding-left: $euiSize; } @@ -55,5 +56,7 @@ padding: $euiSize $euiSizeXS $euiSize $euiSize; overflow-x: hidden; overflow-y: scroll; + padding-left: $euiFormMaxWidth + $euiSize; + margin-left: -$euiFormMaxWidth; } } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 5e5cfc3402f10..e741b9ee243db 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -955,7 +955,7 @@ describe('workspace_panel', () => { visualizationState: {}, }); initComponent(); - expect(instance.find(DragDrop).prop('dropType')).toBeTruthy(); + expect(instance.find(DragDrop).prop('dropTypes')).toBeTruthy(); }); it('should refuse to drop if there are no suggestions', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index b15b659f2d221..8a0b9922c736b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -354,7 +354,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ className="lnsWorkspacePanel__dragDrop" dataTestSubj="lnsWorkspace" draggable={false} - dropType={suggestionForDraggedField ? 'field_add' : undefined} + dropTypes={suggestionForDraggedField ? ['field_add'] : undefined} onDrop={onDrop} value={dropProps.value} order={dropProps.order} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 669758c5193a6..e6a38ce2bb713 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -827,7 +827,7 @@ describe('IndexPattern Data Panel', () => { }); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); expect(props.indexPatternFieldEditor.openEditor).toHaveBeenCalledWith( expect.objectContaining({ @@ -860,10 +860,11 @@ describe('IndexPattern Data Panel', () => { .prop('children') as ReactElement).props.items[0].props.onClick(); }); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); + await (props.indexPatternFieldEditor.openEditor as jest.Mock).mock.calls[0][0].onSave(); // wait for indx pattern to be loaded - await new Promise((r) => setTimeout(r, 0)); + await act(async () => await new Promise((r) => setTimeout(r, 0))); expect(props.onUpdateIndexPattern).toHaveBeenCalledWith( expect.objectContaining({ fields: [ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts deleted file mode 100644 index 4f73454b68811..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ /dev/null @@ -1,1225 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; -import { IndexPatternDimensionEditorProps } from './dimension_panel'; -import { onDrop, getDropProps } from './droppable'; -import { DraggingIdentifier } from '../../drag_drop'; -import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; -import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { IndexPatternPrivateState } from '../types'; -import { documentField } from '../document_field'; -import { OperationMetadata, DropType } from '../../types'; -import { IndexPatternColumn, MedianIndexPatternColumn } from '../operations'; -import { getFieldByNameFactory } from '../pure_helpers'; - -const fields = [ - { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'bytes', - displayName: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'memory', - displayName: 'memory', - type: 'number', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'source', - displayName: 'source', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'src', - displayName: 'src', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - { - name: 'dest', - displayName: 'dest', - type: 'string', - aggregatable: true, - searchable: true, - exists: true, - }, - documentField, -]; - -const expectedIndexPatterns = { - foo: { - id: 'foo', - title: 'my-fake-index-pattern', - timeFieldName: 'timestamp', - hasExistence: true, - hasRestrictions: false, - fields, - getFieldByName: getFieldByNameFactory(fields), - }, -}; - -const defaultDragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - humanData: { - label: 'Column 2', - }, -}; - -const draggingField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { label: 'Label' }, -}; - -/** - * The datasource exposes four main pieces of code which are tested at - * an integration test level. The main reason for this fairly high level - * of testing is that there is a lot of UI logic that isn't easily - * unit tested, such as the transient invalid state. - * - * - Dimension trigger: Not tested here - * - Dimension editor component: First half of the tests - * - * - getDropProps: Returns drop types that are possible for the current dragging field or other dimension - * - onDrop: Correct application of drop logic - */ -describe('IndexPatternDimensionEditorPanel', () => { - let state: IndexPatternPrivateState; - let setState: jest.Mock; - let defaultProps: IndexPatternDimensionEditorProps; - - beforeEach(() => { - state = { - indexPatternRefs: [], - indexPatterns: expectedIndexPatterns, - currentIndexPatternId: 'foo', - isFirstExistenceFetch: false, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1'], - columns: { - col1: { - label: 'Date histogram of timestamp', - customLabel: true, - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - }, - incompleteColumns: {}, - }, - }, - }; - - setState = jest.fn(); - - defaultProps = { - state, - setState, - dateRange: { fromDate: 'now-1d', toDate: 'now' }, - columnId: 'col1', - layerId: 'first', - uniqueLabel: 'stuff', - groupId: 'group1', - filterOperations: () => true, - storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, - savedObjectsClient: {} as SavedObjectsClientContract, - http: {} as HttpSetup, - data: ({ - fieldFormats: ({ - getType: jest.fn().mockReturnValue({ - id: 'number', - title: 'Number', - }), - getDefaultType: jest.fn().mockReturnValue({ - id: 'bytes', - title: 'Bytes', - }), - } as unknown) as DataPublicPluginStart['fieldFormats'], - } as unknown) as DataPublicPluginStart, - core: {} as CoreSetup, - dimensionGroups: [], - }; - - jest.clearAllMocks(); - }); - - const groupId = 'a'; - - describe('getDropProps', () => { - it('returns undefined if no drag is happening', () => { - const dragging = { - name: 'bar', - id: 'bar', - humanData: { label: 'Label' }, - }; - expect(getDropProps({ ...defaultProps, groupId, dragging })).toBe(undefined); - }); - - it('returns undefined if the dragged item has no field', () => { - const dragging = { - name: 'bar', - id: 'bar', - humanData: { label: 'Label' }, - }; - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging, - }) - ).toBe(undefined); - }); - - it('returns undefined if field is not supported by filterOperations', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - indexPatternId: 'foo', - field: { type: 'string', name: 'mystring', aggregatable: true }, - id: 'mystring', - humanData: { label: 'Label' }, - }, - filterOperations: () => false, - }) - ).toBe(undefined); - }); - - it('returns remove_add if the field is supported by filterOperations and the dropTarget is an existing column', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: draggingField, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toEqual({ dropType: 'field_replace', nextLabel: 'Intervals' }); - }); - - it('returns undefined if the field belongs to another index pattern', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - field: { type: 'number', name: 'bar', aggregatable: true }, - indexPatternId: 'foo2', - id: 'bar', - humanData: { label: 'Label' }, - }, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }) - ).toBe(undefined); - }); - - it('returns undefined if the dragged field is already in use by this operation', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - field: { - name: 'timestamp', - displayName: 'timestampLabel', - type: 'date', - aggregatable: true, - searchable: true, - exists: true, - }, - indexPatternId: 'foo', - id: 'bar', - humanData: { label: 'Label' }, - }, - }) - ).toBe(undefined); - }); - - it('returns move if the dragged column is compatible', () => { - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - columnId: 'col2', - }) - ).toEqual({ dropType: 'move_compatible' }); - }); - - it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Date histogram of timestamp (1)', - customLabel: true, - dataType: 'date', - isBucketed: true, - - // Private - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - }, - }; - - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - - columnId: 'col2', - }) - ).toEqual(undefined); - }); - - it('returns replace_incompatible if dropping column to existing incompatible column', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Sum of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'sum', - sourceField: 'bytes', - }, - }, - }; - - expect( - getDropProps({ - ...defaultProps, - groupId, - dragging: { - columnId: 'col1', - groupId: 'b', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed === false, - }) - ).toEqual({ dropType: 'replace_incompatible', nextLabel: 'Unique count' }); - }); - }); - describe('onDrop', () => { - it('appends the dropped column when a field is dropped', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - dropType: 'field_replace', - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col1', 'col2'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }, - }, - }, - }); - }); - - it('selects the specific operation that was valid on drop', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - columnId: 'col2', - filterOperations: (op: OperationMetadata) => op.isBucketed, - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col1', 'col2'], - columns: { - ...state.layers.first.columns, - col2: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }, - }, - }, - }); - }); - - it('updates a column when a field is dropped', () => { - onDrop({ - ...defaultProps, - droppedItem: draggingField, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - }), - }), - }, - }); - }); - - it('keeps the operation when dropping a different compatible field', () => { - onDrop({ - ...defaultProps, - droppedItem: { - field: { name: 'memory', type: 'number', aggregatable: true }, - indexPatternId: 'foo', - id: '1', - }, - state: { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1'], - columns: { - col1: { - label: 'Sum of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'sum', - sourceField: 'bytes', - }, - }, - }, - }, - }, - dropType: 'field_replace', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: expect.objectContaining({ - columns: expect.objectContaining({ - col1: expect.objectContaining({ - operationType: 'sum', - dataType: 'number', - sourceField: 'memory', - }), - }), - }), - }, - }); - }); - - it('updates the column id when moving an operation to an empty dimension', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'bar', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: dragging, - columnId: 'col2', - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...state.layers.first, - columnOrder: ['col2'], - columns: { - col2: state.layers.first.columns.col1, - }, - }, - }, - }); - }); - - it('replaces an operation when moving to a populated dimension', () => { - const testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - - col2: { - label: 'Top values of src', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'column', columnId: 'col3' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'src', - }, - col3: { - label: 'Count', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'count', - sourceField: 'Records', - }, - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: defaultDragging, - state: testState, - dropType: 'replace_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col3'], - columns: { - col1: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - }, - }, - }); - }); - - describe('dimension group aware ordering and copying', () => { - let dragging: DraggingIdentifier; - let testState: IndexPatternPrivateState; - beforeEach(() => { - dragging = { - columnId: 'col2', - groupId: 'b', - layerId: 'first', - id: 'col2', - humanData: { - label: '', - }, - }; - testState = { ...state }; - testState.layers.first = { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3', 'col4'], - columns: { - col1: testState.layers.first.columns.col1, - col2: { - label: 'Top values of src', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'src', - }, - col3: { - label: 'Top values of dest', - dataType: 'string', - isBucketed: true, - - // Private - operationType: 'terms', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'desc', - size: 10, - }, - sourceField: 'dest', - }, - col4: { - label: 'Median of bytes', - dataType: 'number', - isBucketed: false, - - // Private - operationType: 'median', - sourceField: 'bytes', - }, - }, - }; - }); - const dimensionGroups = [ - { - accessors: [], - groupId: 'a', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - { - accessors: [{ columnId: 'col1' }, { columnId: 'col2' }, { columnId: 'col3' }], - groupId: 'b', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - { - accessors: [{ columnId: 'col4' }], - groupId: 'c', - supportsMoreColumns: true, - hideGrouping: true, - groupLabel: '', - filterOperations: () => false, - }, - ]; - - it('respects groups on moving operations from one group to another', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging col2 into newCol in group a - onDrop({ - ...defaultProps, - columnId: 'newCol', - droppedItem: dragging, - state: testState, - groupId: 'a', - dimensionGroups, - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col3', 'col4'], - columns: { - newCol: testState.layers.first.columns.col2, - col1: testState.layers.first.columns.col1, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('respects groups on moving operations from one group to another with overwrite', () => { - // config: - // a: col1, - // b: col2, col3 - // c: col4 - // dragging col3 onto col1 in group a - const draggingCol3 = { - columnId: 'col3', - groupId: 'b', - layerId: 'first', - id: 'col3', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'col1', - droppedItem: draggingCol3, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - dropType: 'move_compatible', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'col4'], - columns: { - col1: testState.layers.first.columns.col3, - col2: testState.layers.first.columns.col2, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('moves newly created dimension to the bottom of the current group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // dragging col1 into newCol in group b - const draggingCol1 = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'move_compatible', - droppedItem: draggingCol1, - state: testState, - groupId: 'b', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col2', 'col3', 'newCol', 'col4'], - columns: { - newCol: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('appends the dropped column in the right place when a field is dropped', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging field into newCol in group a - const draggingBytesField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { - label: '', - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: draggingBytesField, - columnId: 'newCol', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: 'a', - dimensionGroups, - dropType: 'field_add', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], - columns: { - newCol: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - incompleteColumns: {}, - }, - }, - }); - }); - - it('appends the dropped column in the right place respecting custom nestingOrder', () => { - // config: - // a: - // b: col1, col2, col3 - // c: col4 - // dragging field into newCol in group a - const draggingBytesField = { - field: { type: 'number', name: 'bytes', aggregatable: true }, - indexPatternId: 'foo', - id: 'bar', - humanData: { - label: '', - }, - }; - - onDrop({ - ...defaultProps, - droppedItem: draggingBytesField, - columnId: 'newCol', - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - groupId: 'a', - dimensionGroups: [ - // a and b are ordered in reverse visually, but nesting order keeps them in place for column order - { ...dimensionGroups[1], nestingOrder: 1 }, - { ...dimensionGroups[0], nestingOrder: 0 }, - { ...dimensionGroups[2] }, - ], - dropType: 'field_add', - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...state, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], - columns: { - newCol: expect.objectContaining({ - dataType: 'number', - sourceField: 'bytes', - }), - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - incompleteColumns: {}, - }, - }, - }); - }); - - it('copies column to the bottom of the current group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // copying col1 within group a - const draggingCol1 = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'duplicate_in_group', - droppedItem: draggingCol1, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], - columns: { - col1: testState.layers.first.columns.col1, - newCol: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - col4: testState.layers.first.columns.col4, - }, - }, - }, - }); - }); - - it('moves incompatible column to the bottom of the target group', () => { - // config: - // a: col1 - // b: col2, col3 - // c: col4 - // dragging col4 into newCol in group a - const draggingCol4 = { - columnId: 'col4', - groupId: 'c', - layerId: 'first', - id: 'col4', - humanData: { - label: '', - }, - }; - onDrop({ - ...defaultProps, - columnId: 'newCol', - dropType: 'move_incompatible', - droppedItem: draggingCol4, - state: testState, - groupId: 'a', - dimensionGroups: [ - { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, - { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, - { ...dimensionGroups[2] }, - ], - }); - - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'newCol', 'col2', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - newCol: expect.objectContaining({ - sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) - .sourceField, - }), - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - incompleteColumns: {}, - }, - }, - }); - }); - }); - - it('if dnd is reorder, it correctly reorders columns', () => { - const testState: IndexPatternPrivateState = { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - operationType: 'date_histogram', - params: { - interval: '1d', - }, - sourceField: 'timestamp', - }, - col2: { - label: 'Top values of bar', - dataType: 'number', - isBucketed: true, - operationType: 'terms', - sourceField: 'bar', - params: { - orderBy: { type: 'alphabetical' }, - orderDirection: 'asc', - size: 5, - }, - }, - col3: { - operationType: 'average', - sourceField: 'memory', - label: 'average of memory', - dataType: 'number', - isBucketed: false, - }, - }, - }, - }, - }; - - const metricDragging = { - columnId: 'col3', - groupId: 'a', - layerId: 'first', - id: 'col3', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: metricDragging, - state: testState, - dropType: 'duplicate_in_group', - columnId: 'newCol', - }); - // metric is appended - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'col3', 'newCol'], - columns: { - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - newCol: testState.layers.first.columns.col3, - }, - }, - }, - }); - - const bucketDragging = { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - humanData: { label: 'Label' }, - }; - - onDrop({ - ...defaultProps, - droppedItem: bucketDragging, - state: testState, - dropType: 'duplicate_in_group', - columnId: 'newCol', - }); - - // bucket is placed after the last existing bucket - expect(setState).toHaveBeenCalledWith({ - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder: ['col1', 'col2', 'newCol', 'col3'], - columns: { - col1: testState.layers.first.columns.col1, - col2: testState.layers.first.columns.col2, - newCol: testState.layers.first.columns.col2, - col3: testState.layers.first.columns.col3, - }, - }, - }, - }); - }); - - it('if dropType is reorder, it correctly reorders columns', () => { - const dragging = { - columnId: 'col1', - groupId: 'a', - layerId: 'first', - id: 'col1', - humanData: { label: 'Label' }, - }; - const testState = { - ...state, - layers: { - first: { - indexPatternId: 'foo', - columnOrder: ['col1', 'col2', 'col3'], - columns: { - col1: { - label: 'Date histogram of timestamp', - dataType: 'date', - isBucketed: true, - } as IndexPatternColumn, - col2: { - label: 'Top values of bar', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, - col3: { - label: 'Top values of memory', - dataType: 'number', - isBucketed: true, - } as IndexPatternColumn, - }, - }, - }, - }; - - const defaultReorderDropParams = { - ...defaultProps, - dragging, - droppedItem: dragging, - state: testState, - filterOperations: (op: OperationMetadata) => op.dataType === 'number', - dropType: 'reorder' as DropType, - }; - - const stateWithColumnOrder = (columnOrder: string[]) => { - return { - ...testState, - layers: { - first: { - ...testState.layers.first, - columnOrder, - columns: { - ...testState.layers.first.columns, - }, - }, - }, - }; - }; - - // first element to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - }); - expect(setState).toBeCalledTimes(1); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); - - // last element to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { - columnId: 'col3', - groupId: 'a', - layerId: 'first', - id: 'col3', - }, - }); - expect(setState).toBeCalledTimes(2); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); - - // middle column to first - onDrop({ - ...defaultReorderDropParams, - columnId: 'col1', - droppedItem: { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - }, - }); - expect(setState).toBeCalledTimes(3); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); - - // middle column to last - onDrop({ - ...defaultReorderDropParams, - columnId: 'col3', - droppedItem: { - columnId: 'col2', - groupId: 'a', - layerId: 'first', - id: 'col2', - }, - }); - expect(setState).toBeCalledTimes(4); - expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts deleted file mode 100644 index e846db718f1d3..0000000000000 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - DatasourceDimensionDropProps, - DatasourceDimensionDropHandlerProps, - isDraggedOperation, - DraggedOperation, - DropType, -} from '../../types'; -import { IndexPatternColumn } from '../indexpattern'; -import { - insertOrReplaceColumn, - deleteColumn, - getOperationTypesForField, - getColumnOrder, - reorderByGroups, - getOperationDisplay, -} from '../operations'; -import { mergeLayer } from '../state_helpers'; -import { hasField, isDraggedField } from '../utils'; -import { IndexPatternPrivateState, DraggedField } from '../types'; -import { trackUiEvent } from '../../lens_ui_telemetry'; -import { DragContextState } from '../../drag_drop/providers'; - -type DropHandlerProps = DatasourceDimensionDropHandlerProps & { - droppedItem: T; -}; - -const operationLabels = getOperationDisplay(); - -export function getDropProps( - props: DatasourceDimensionDropProps & { - dragging: DragContextState['dragging']; - groupId: string; - } -): { dropType: DropType; nextLabel?: string } | undefined { - const { dragging } = props; - if (!dragging) { - return; - } - - const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; - - const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; - if (isDraggedField(dragging)) { - const operationsForNewField = getOperationTypesForField(dragging.field, props.filterOperations); - - if (!!(layerIndexPatternId === dragging.indexPatternId && operationsForNewField.length)) { - const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; - if (!currentColumn) { - return { dropType: 'field_add', nextLabel: highestPriorityOperationLabel }; - } else if ( - (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name) || - !hasField(currentColumn) - ) { - const persistingOperationLabel = - currentColumn && - operationsForNewField.includes(currentColumn.operationType) && - operationLabels[currentColumn.operationType].displayName; - - return { - dropType: 'field_replace', - nextLabel: persistingOperationLabel || highestPriorityOperationLabel, - }; - } - } - return; - } - - if ( - isDraggedOperation(dragging) && - dragging.layerId === props.layerId && - props.columnId !== dragging.columnId - ) { - // same group - if (props.groupId === dragging.groupId) { - if (currentColumn) { - return { dropType: 'reorder' }; - } - return { dropType: 'duplicate_in_group' }; - } - - // compatible group - const op = props.state.layers[dragging.layerId].columns[dragging.columnId]; - if ( - !op || - (currentColumn && - hasField(currentColumn) && - hasField(op) && - currentColumn.sourceField === op.sourceField) - ) { - return; - } - if (props.filterOperations(op)) { - if (currentColumn) { - return { dropType: 'replace_compatible' }; // in the future also 'swap_compatible' and 'duplicate_compatible' - } else { - return { dropType: 'move_compatible' }; // in the future also 'duplicate_compatible' - } - } - - // suggest - const field = - hasField(op) && props.state.indexPatterns[layerIndexPatternId].getFieldByName(op.sourceField); - const operationsForNewField = field && getOperationTypesForField(field, props.filterOperations); - - if (operationsForNewField && operationsForNewField?.length) { - const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; - - if (currentColumn) { - const persistingOperationLabel = - currentColumn && - operationsForNewField.includes(currentColumn.operationType) && - operationLabels[currentColumn.operationType].displayName; - return { - dropType: 'replace_incompatible', - nextLabel: persistingOperationLabel || highestPriorityOperationLabel, - }; // in the future also 'swap_incompatible', 'duplicate_incompatible' - } else { - return { - dropType: 'move_incompatible', - nextLabel: highestPriorityOperationLabel, - }; // in the future also 'duplicate_incompatible' - } - } - } -} - -export function onDrop(props: DatasourceDimensionDropHandlerProps) { - const { droppedItem, dropType } = props; - - if (dropType === 'field_add' || dropType === 'field_replace') { - return operationOnDropMap[dropType]({ - ...props, - droppedItem: droppedItem as DraggedField, - }); - } - return operationOnDropMap[dropType]({ - ...props, - droppedItem: droppedItem as DraggedOperation, - }); -} - -const operationOnDropMap = { - field_add: onFieldDrop, - field_replace: onFieldDrop, - reorder: onReorderDrop, - duplicate_in_group: onSameGroupDuplicateDrop, - move_compatible: onMoveDropToCompatibleGroup, - replace_compatible: onMoveDropToCompatibleGroup, - move_incompatible: onMoveDropToNonCompatibleGroup, - replace_incompatible: onMoveDropToNonCompatibleGroup, -}; - -function reorderElements(items: string[], dest: string, src: string) { - const result = items.filter((c) => c !== src); - const destIndex = items.findIndex((c) => c === src); - const destPosition = result.indexOf(dest); - - const srcIndex = items.findIndex((c) => c === dest); - - result.splice(destIndex < srcIndex ? destPosition + 1 : destPosition, 0, src); - return result; -} - -function onReorderDrop({ - columnId, - setState, - state, - layerId, - droppedItem, -}: DropHandlerProps) { - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: reorderElements( - state.layers[layerId].columnOrder, - columnId, - droppedItem.columnId - ), - }, - }) - ); - - return true; -} - -function onMoveDropToNonCompatibleGroup(props: DropHandlerProps) { - const { columnId, setState, state, layerId, droppedItem, dimensionGroups, groupId } = props; - - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - const field = - hasField(op) && state.indexPatterns[layer.indexPatternId].getFieldByName(op.sourceField); - if (!field) { - return false; - } - - const operationsForNewField = getOperationTypesForField(field, props.filterOperations); - - if (!operationsForNewField.length) { - return false; - } - - const currentIndexPattern = state.indexPatterns[layer.indexPatternId]; - // Detects if we can change the field only, otherwise change field + operation - - const selectedColumn: IndexPatternColumn | null = layer.columns[columnId] || null; - - const fieldIsCompatibleWithCurrent = - selectedColumn && operationsForNewField.includes(selectedColumn.operationType); - - const newLayer = insertOrReplaceColumn({ - layer: deleteColumn({ - layer, - columnId: droppedItem.columnId, - indexPattern: currentIndexPattern, - }), - columnId, - indexPattern: currentIndexPattern, - op: fieldIsCompatibleWithCurrent ? selectedColumn.operationType : operationsForNewField[0], - field, - visualizationGroups: dimensionGroups, - targetGroup: groupId, - }); - - trackUiEvent('drop_onto_dimension'); - setState( - mergeLayer({ - state, - layerId, - newLayer: { - ...newLayer, - }, - }) - ); - - return { deleted: droppedItem.columnId }; -} - -function onSameGroupDuplicateDrop({ - columnId, - setState, - state, - layerId, - droppedItem, - dimensionGroups, - groupId, -}: DropHandlerProps) { - const layer = state.layers[layerId]; - - const op = { ...layer.columns[droppedItem.columnId] }; - const newColumns = { - ...layer.columns, - [columnId]: op, - }; - - const newColumnOrder = [...layer.columnOrder]; - // put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array - // then reorder based on dimension groups if necessary - const insertionIndex = op.isBucketed - ? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed) - : newColumnOrder.length; - newColumnOrder.splice(insertionIndex, 0, columnId); - - const newLayer = { - ...layer, - columnOrder: newColumnOrder, - columns: newColumns, - }; - - const updatedColumnOrder = getColumnOrder(newLayer); - - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: updatedColumnOrder, - columns: newColumns, - }, - }) - ); - return true; -} - -function onMoveDropToCompatibleGroup({ - columnId, - setState, - state, - layerId, - droppedItem, - dimensionGroups, - groupId, -}: DropHandlerProps) { - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - const newColumns = { ...layer.columns }; - delete newColumns[droppedItem.columnId]; - newColumns[columnId] = op; - - const newColumnOrder = [...layer.columnOrder]; - const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); - const newIndex = newColumnOrder.findIndex((c) => c === columnId); - - if (newIndex === -1) { - // for newly created columns, remove the old entry and add the last one to the end - newColumnOrder.splice(oldIndex, 1); - newColumnOrder.push(columnId); - } else { - // for drop to replace, reuse the same index - newColumnOrder[oldIndex] = columnId; - } - const newLayer = { - ...layer, - columnOrder: newColumnOrder, - columns: newColumns, - }; - - const updatedColumnOrder = getColumnOrder(newLayer); - - reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - - newLayer: { - columnOrder: updatedColumnOrder, - columns: newColumns, - }, - }) - ); - return { deleted: droppedItem.columnId }; -} - -function onFieldDrop(props: DropHandlerProps) { - const { columnId, setState, state, layerId, droppedItem, groupId, dimensionGroups } = props; - - const operationsForNewField = getOperationTypesForField( - droppedItem.field, - props.filterOperations - ); - - if (!isDraggedField(droppedItem) || !operationsForNewField.length) { - // TODO: What do we do if we couldn't find a column? - return false; - } - - const layer = state.layers[layerId]; - - const selectedColumn: IndexPatternColumn | null = layer.columns[columnId] || null; - const currentIndexPattern = state.indexPatterns[layer.indexPatternId]; - - // Detects if we can change the field only, otherwise change field + operation - const fieldIsCompatibleWithCurrent = - selectedColumn && operationsForNewField.includes(selectedColumn.operationType); - - const newLayer = insertOrReplaceColumn({ - layer, - columnId, - indexPattern: currentIndexPattern, - op: fieldIsCompatibleWithCurrent ? selectedColumn.operationType : operationsForNewField[0], - field: droppedItem.field, - visualizationGroups: dimensionGroups, - targetGroup: groupId, - }); - - trackUiEvent('drop_onto_dimension'); - const hasData = Object.values(state.layers).some(({ columns }) => columns.length); - trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); - setState(mergeLayer({ state, layerId, newLayer })); - return true; -} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts new file mode 100644 index 0000000000000..051feb331aec4 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -0,0 +1,1556 @@ +/* + * 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 { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; +import { IndexPatternDimensionEditorProps } from '../dimension_panel'; +import { onDrop } from './on_drop_handler'; +import { getDropProps } from './get_drop_props'; +import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { IndexPatternLayer, IndexPatternPrivateState } from '../../types'; +import { documentField } from '../../document_field'; +import { OperationMetadata, DropType } from '../../../types'; +import { IndexPatternColumn, MedianIndexPatternColumn } from '../../operations'; +import { getFieldByNameFactory } from '../../pure_helpers'; + +const fields = [ + { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'bytes', + displayName: 'bytes', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'memory', + displayName: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'source', + displayName: 'source', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'src', + displayName: 'src', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + { + name: 'dest', + displayName: 'dest', + type: 'string', + aggregatable: true, + searchable: true, + exists: true, + }, + documentField, +]; + +const expectedIndexPatterns = { + foo: { + id: 'foo', + title: 'my-fake-index-pattern', + timeFieldName: 'timestamp', + hasExistence: true, + hasRestrictions: false, + fields, + getFieldByName: getFieldByNameFactory(fields), + }, +}; + +const dimensionGroups = [ + { + accessors: [], + groupId: 'a', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed, + }, + { + accessors: [{ columnId: 'col1' }, { columnId: 'col2' }, { columnId: 'col3' }], + groupId: 'b', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed, + }, + { + accessors: [{ columnId: 'col4' }], + groupId: 'c', + supportsMoreColumns: true, + hideGrouping: true, + groupLabel: '', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }, +]; + +const oneColumnLayer: IndexPatternLayer = { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Date histogram of timestamp', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + incompleteColumns: {}, +}; + +const multipleColumnsLayer: IndexPatternLayer = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: oneColumnLayer.columns.col1, + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Top values of dest', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'dest', + }, + col4: { + label: 'Median of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'median', + sourceField: 'bytes', + }, + }, +}; + +const draggingField = { + field: { type: 'number', name: 'bytes', aggregatable: true }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, +}; + +const draggingCol1 = { + columnId: 'col1', + groupId: 'a', + layerId: 'first', + id: 'col1', + humanData: { label: 'Column 1' }, +}; + +const draggingCol2 = { + columnId: 'col2', + groupId: 'b', + layerId: 'first', + id: 'col2', + humanData: { label: 'Column 2' }, + filterOperations: (op: OperationMetadata) => op.isBucketed, +}; + +const draggingCol3 = { + columnId: 'col3', + groupId: 'b', + layerId: 'first', + id: 'col3', + humanData: { + label: '', + }, +}; + +const draggingCol4 = { + columnId: 'col4', + groupId: 'c', + layerId: 'first', + id: 'col4', + humanData: { + label: '', + }, + filterOperations: (op: OperationMetadata) => op.isBucketed === false, +}; + +/** + * The datasource exposes four main pieces of code which are tested at + * an integration test level. The main reason for this fairly high level + * of testing is that there is a lot of UI logic that isn't easily + * unit tested, such as the transient invalid state. + * + * - Dimension trigger: Not tested here + * - Dimension editor component: First half of the tests + * + * - getDropProps: Returns drop types that are possible for the current dragging field or other dimension + * - onDrop: Correct application of drop logic + */ +describe('IndexPatternDimensionEditorPanel', () => { + let state: IndexPatternPrivateState; + let setState: jest.Mock; + let defaultProps: IndexPatternDimensionEditorProps; + + beforeEach(() => { + state = { + indexPatternRefs: [], + indexPatterns: expectedIndexPatterns, + currentIndexPatternId: 'foo', + isFirstExistenceFetch: false, + existingFields: { + 'my-fake-index-pattern': { + timestamp: true, + bytes: true, + memory: true, + source: true, + }, + }, + layers: { first: { ...oneColumnLayer } }, + }; + + setState = jest.fn(); + + defaultProps = { + state, + setState, + dateRange: { fromDate: 'now-1d', toDate: 'now' }, + columnId: 'col1', + layerId: 'first', + uniqueLabel: 'stuff', + groupId: 'group1', + filterOperations: () => true, + storage: {} as IStorageWrapper, + uiSettings: {} as IUiSettingsClient, + savedObjectsClient: {} as SavedObjectsClientContract, + http: {} as HttpSetup, + data: ({ + fieldFormats: ({ + getType: jest.fn().mockReturnValue({ + id: 'number', + title: 'Number', + }), + getDefaultType: jest.fn().mockReturnValue({ + id: 'bytes', + title: 'Bytes', + }), + } as unknown) as DataPublicPluginStart['fieldFormats'], + } as unknown) as DataPublicPluginStart, + core: {} as CoreSetup, + dimensionGroups: [], + }; + + jest.clearAllMocks(); + }); + + const groupId = 'a'; + + describe('getDropProps', () => { + it('returns undefined if no drag is happening', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + }) + ).toBe(undefined); + }); + + it('returns undefined if the dragged item has no field', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }, + }) + ).toBe(undefined); + }); + + describe('dragging a field', () => { + it('returns undefined if field is not supported by filterOperations', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: draggingField, + filterOperations: () => false, + }) + ).toBe(undefined); + }); + + it('returns field_replace if the field is supported by filterOperations and the dropTarget is an existing column', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toEqual({ dropTypes: ['field_replace'], nextLabel: 'Intervals' }); + }); + + it('returns field_add if the field is supported by filterOperations and the dropTarget is an empty column', () => { + expect( + getDropProps({ + ...defaultProps, + columnId: 'newId', + groupId, + dragging: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toEqual({ dropTypes: ['field_add'], nextLabel: 'Intervals' }); + }); + + it('returns undefined if the field belongs to another index pattern', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + field: { type: 'number', name: 'bar', aggregatable: true }, + indexPatternId: 'foo2', + id: 'bar', + humanData: { label: 'Label' }, + }, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }) + ).toBe(undefined); + }); + + it('returns undefined if the dragged field is already in use by this operation', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + field: { + name: 'timestamp', + displayName: 'timestampLabel', + type: 'date', + aggregatable: true, + searchable: true, + exists: true, + }, + indexPatternId: 'foo', + id: 'bar', + humanData: { label: 'Label' }, + }, + }) + ).toBe(undefined); + }); + }); + + describe('dragging a column', () => { + it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Date histogram of timestamp (1)', + customLabel: true, + dataType: 'date', + isBucketed: true, + + // Private + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual(undefined); + }); + + it('returns reorder if drop target and droppedItem columns are from the same group and both are existing', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { ...draggingCol1, groupId }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: ['reorder'], + }); + }); + + it('returns duplicate_compatible if drop target and droppedItem columns are from the same group and drop target id is a new column', () => { + expect( + getDropProps({ + ...defaultProps, + columnId: 'newId', + groupId, + dragging: { + ...draggingCol1, + groupId, + }, + }) + ).toEqual({ dropTypes: ['duplicate_compatible'] }); + }); + + it('returns compatible drop types if the dragged column is compatible', () => { + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual({ dropTypes: ['move_compatible', 'duplicate_compatible'] }); + }); + + it('returns incompatible drop target types if dropping column to existing incompatible column', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: [ + 'replace_incompatible', + 'replace_duplicate_incompatible', + 'swap_incompatible', + ], + nextLabel: 'Unique count', + }); + }); + + it('does not return swap_incompatible if current dropTarget column cannot be swapped to the group of dragging column', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Count of records', + dataType: 'number', + isBucketed: false, + sourceField: 'Records', + operationType: 'count', + }, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + groupId, + dragging: { + columnId: 'col1', + groupId: 'b', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + filterOperations: (op: OperationMetadata) => op.isBucketed === true, + }, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed === false, + }) + ).toEqual({ + dropTypes: ['replace_incompatible', 'replace_duplicate_incompatible'], + nextLabel: 'Unique count', + }); + }); + }); + }); + + describe('onDrop', () => { + describe('dropping a field', () => { + it('updates a column when a field is dropped', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }), + }), + }, + }); + }); + it('selects the specific operation that was valid on drop', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.isBucketed, + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }, + }, + }, + }); + }); + it('keeps the operation when dropping a different compatible field', () => { + onDrop({ + ...defaultProps, + droppedItem: { + field: { name: 'memory', type: 'number', aggregatable: true }, + indexPatternId: 'foo', + id: '1', + }, + state: { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1'], + columns: { + col1: { + label: 'Sum of bytes', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }, + }, + }, + dropType: 'field_replace', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: expect.objectContaining({ + columns: expect.objectContaining({ + col1: expect.objectContaining({ + operationType: 'sum', + dataType: 'number', + sourceField: 'memory', + }), + }), + }), + }, + }); + }); + it('appends the dropped column when a field is dropped', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingField, + dropType: 'field_replace', + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col1', 'col2'], + columns: { + ...state.layers.first.columns, + col2: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + }, + }, + }, + }); + }); + it('dimensionGroups are defined - appends the dropped column in the right place when a field is dropped', () => { + const testState = { ...state }; + testState.layers.first = { ...multipleColumnsLayer }; + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging field into newCol in group a + + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'newCol', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', + dimensionGroups, + dropType: 'field_add', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + }); + + describe('dropping a dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'first', + id: 'col1', + humanData: { label: 'Label' }, + }; + + it('sets correct order in group for metric and bucket columns when duplicating a column in group', () => { + const testState: IndexPatternPrivateState = { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + operationType: 'date_histogram', + params: { + interval: '1d', + }, + sourceField: 'timestamp', + }, + col2: { + label: 'Top values of bar', + dataType: 'number', + isBucketed: true, + operationType: 'terms', + sourceField: 'bar', + params: { + orderBy: { type: 'alphabetical' }, + orderDirection: 'asc', + size: 5, + }, + }, + col3: { + operationType: 'average', + sourceField: 'memory', + label: 'average of memory', + dataType: 'number', + isBucketed: false, + }, + }, + }, + }, + }; + + const metricDragging = { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + droppedItem: metricDragging, + state: testState, + dropType: 'duplicate_compatible', + columnId: 'newCol', + }); + // metric is appended + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'newCol'], + columns: { + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + newCol: testState.layers.first.columns.col3, + }, + }, + }, + }); + + const bucketDragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + humanData: { label: 'Label' }, + }; + + onDrop({ + ...defaultProps, + droppedItem: bucketDragging, + state: testState, + dropType: 'duplicate_compatible', + columnId: 'newCol', + }); + + // bucket is placed after the last existing bucket + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'newCol', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + newCol: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + }, + }, + }); + }); + + it('sets correct order in group when reordering a column in group', () => { + const testState = { + ...state, + layers: { + first: { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: { + label: 'Date histogram of timestamp', + dataType: 'date', + isBucketed: true, + } as IndexPatternColumn, + col2: { + label: 'Top values of bar', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + col3: { + label: 'Top values of memory', + dataType: 'number', + isBucketed: true, + } as IndexPatternColumn, + }, + }, + }, + }; + + const defaultReorderDropParams = { + ...defaultProps, + dragging, + droppedItem: draggingCol1, + state: testState, + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + dropType: 'reorder' as DropType, + }; + + const stateWithColumnOrder = (columnOrder: string[]) => { + return { + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder, + columns: { + ...testState.layers.first.columns, + }, + }, + }, + }; + }; + + // first element to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + }); + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col3', 'col1'])); + + // last element to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col3', + groupId: 'a', + layerId: 'first', + id: 'col3', + }, + }); + expect(setState).toBeCalledTimes(2); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col3', 'col1', 'col2'])); + + // middle column to first + onDrop({ + ...defaultReorderDropParams, + columnId: 'col1', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(3); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col2', 'col1', 'col3'])); + + // middle column to last + onDrop({ + ...defaultReorderDropParams, + columnId: 'col3', + droppedItem: { + columnId: 'col2', + groupId: 'a', + layerId: 'first', + id: 'col2', + }, + }); + expect(setState).toBeCalledTimes(4); + expect(setState).toHaveBeenCalledWith(stateWithColumnOrder(['col1', 'col3', 'col2'])); + }); + + it('updates the column id when moving an operation to an empty dimension', () => { + onDrop({ + ...defaultProps, + droppedItem: draggingCol1, + columnId: 'col2', + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...state.layers.first, + columnOrder: ['col2'], + columns: { + col2: state.layers.first.columns.col1, + }, + }, + }, + }); + }); + + it('replaces an operation when moving to a populated dimension', () => { + const testState = { ...state }; + testState.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', + }, + }, + }; + + onDrop({ + ...defaultProps, + droppedItem: draggingCol2, + state: testState, + dropType: 'replace_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + }, + }, + }); + }); + + describe('dimension group aware ordering and copying', () => { + let testState: IndexPatternPrivateState; + beforeEach(() => { + testState = { ...state }; + testState.layers.first = { ...multipleColumnsLayer }; + }); + + it('respects groups on moving operations between compatible groups', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging col2 into newCol in group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + droppedItem: draggingCol2, + state: testState, + groupId: 'a', + dimensionGroups, + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col3', 'col4'], + columns: { + newCol: testState.layers.first.columns.col2, + col1: testState.layers.first.columns.col1, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on duplicating operations between compatible groups', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging col2 into newCol in group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + droppedItem: draggingCol2, + state: testState, + groupId: 'a', + dimensionGroups, + dropType: 'duplicate_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: testState.layers.first.columns.col2, + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on moving operations between compatible groups with overwrite', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // dragging col3 onto col1 in group a + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'move_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col4'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('respects groups on duplicating operations between compatible groups with overwrite', () => { + // config: + // a: col1, + // b: col2, col3 + // c: col4 + // dragging col3 onto col1 in group a + + onDrop({ + ...defaultProps, + columnId: 'col1', + droppedItem: draggingCol3, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + dropType: 'duplicate_compatible', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col3, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('moves newly created dimension to the bottom of the current group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col1 into newCol in group b + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'move_compatible', + droppedItem: draggingCol1, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col2', 'col3', 'newCol', 'col4'], + columns: { + newCol: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('copies column to the bottom of the current group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // copying col1 within group a + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'duplicate_compatible', + droppedItem: draggingCol1, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + }, + }, + }); + }); + + it('appends the dropped column in the right place respecting custom nestingOrder', () => { + // config: + // a: + // b: col1, col2, col3 + // c: col4 + // dragging field into newCol in group a + + onDrop({ + ...defaultProps, + droppedItem: draggingField, + columnId: 'newCol', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', + dimensionGroups: [ + // a and b are ordered in reverse visually, but nesting order keeps them in place for column order + { ...dimensionGroups[1], nestingOrder: 1 }, + { ...dimensionGroups[0], nestingOrder: 0 }, + { ...dimensionGroups[2] }, + ], + dropType: 'field_add', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...state, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'], + columns: { + newCol: expect.objectContaining({ + dataType: 'number', + sourceField: 'bytes', + }), + col1: testState.layers.first.columns.col1, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('moves incompatible column to the bottom of the target group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into newCol in group a + + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'move_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: expect.objectContaining({ + sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) + .sourceField, + }), + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('copies incompatible column to the bottom of the target group', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into newCol in group a + + onDrop({ + ...defaultProps, + columnId: 'newCol', + dropType: 'duplicate_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + newCol: expect.objectContaining({ + sourceField: (testState.layers.first.columns.col4 as MedianIndexPatternColumn) + .sourceField, + }), + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col4, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('moves incompatible column with overwrite keeping order of target column', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col2 in group b + + onDrop({ + ...defaultProps, + columnId: 'col2', + dropType: 'move_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.first.columns.col1, + col2: { + isBucketed: true, + label: 'Top values of bytes', + operationType: 'terms', + sourceField: 'bytes', + dataType: 'number', + params: { + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'desc', + size: 10, + }, + }, + col3: testState.layers.first.columns.col3, + }, + incompleteColumns: {}, + }, + }, + }); + }); + + it('when swapping compatibly, columns carry order', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col1 + + onDrop({ + ...defaultProps, + columnId: 'col1', + dropType: 'swap_compatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'a', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col4, + col2: testState.layers.first.columns.col2, + col3: testState.layers.first.columns.col3, + col4: testState.layers.first.columns.col1, + }, + }, + }, + }); + }); + + it('when swapping incompatibly, newly created columns take order from the columns they replace', () => { + // config: + // a: col1 + // b: col2, col3 + // c: col4 + // dragging col4 into col2 + + onDrop({ + ...defaultProps, + columnId: 'col2', + dropType: 'swap_incompatible', + droppedItem: draggingCol4, + state: testState, + groupId: 'b', + dimensionGroups: [ + { ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] }, + { ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] }, + { ...dimensionGroups[2] }, + ], + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + first: { + ...testState.layers.first, + columnOrder: ['col1', 'col2', 'col3', 'col4'], + columns: { + col1: testState.layers.first.columns.col1, + col2: { + isBucketed: true, + label: 'Top values of bytes', + operationType: 'terms', + sourceField: 'bytes', + dataType: 'number', + params: { + orderBy: { + type: 'alphabetical', + }, + orderDirection: 'desc', + size: 10, + }, + }, + col3: testState.layers.first.columns.col3, + col4: { + isBucketed: false, + label: 'Unique count of src', + filter: undefined, + operationType: 'unique_count', + sourceField: 'src', + dataType: 'number', + params: undefined, + scale: 'ratio', + }, + }, + incompleteColumns: {}, + }, + }, + }); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts new file mode 100644 index 0000000000000..a98a29aea6682 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -0,0 +1,169 @@ +/* + * 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 { + DatasourceDimensionDropProps, + isDraggedOperation, + DraggedOperation, + DropType, +} from '../../../types'; +import { getOperationDisplay } from '../../operations'; +import { hasField, isDraggedField } from '../../utils'; +import { DragContextState } from '../../../drag_drop/providers'; +import { OperationMetadata } from '../../../types'; +import { getOperationTypesForField } from '../../operations'; +import { IndexPatternColumn } from '../../indexpattern'; +import { + IndexPatternPrivateState, + IndexPattern, + IndexPatternField, + DraggedField, +} from '../../types'; + +type GetDropProps = DatasourceDimensionDropProps & { + dragging?: DragContextState['dragging']; + groupId: string; +}; + +type DropProps = { dropTypes: DropType[]; nextLabel?: string } | undefined; + +const operationLabels = getOperationDisplay(); + +export function getNewOperation( + field: IndexPatternField | undefined | false, + filterOperations: (meta: OperationMetadata) => boolean, + targetColumn: IndexPatternColumn +) { + if (!field) { + return; + } + const newOperations = getOperationTypesForField(field, filterOperations); + if (!newOperations.length) { + return; + } + // Detects if we can change the field only, otherwise change field + operation + const shouldOperationPersist = targetColumn && newOperations.includes(targetColumn.operationType); + return shouldOperationPersist ? targetColumn.operationType : newOperations[0]; +} + +export function getField(column: IndexPatternColumn | undefined, indexPattern: IndexPattern) { + if (!column) { + return; + } + const field = (hasField(column) && indexPattern.getFieldByName(column.sourceField)) || undefined; + return field; +} + +export function getDropProps(props: GetDropProps) { + const { state, columnId, layerId, dragging, groupId, filterOperations } = props; + if (!dragging) { + return; + } + + if (isDraggedField(dragging)) { + return getDropPropsForField({ ...props, dragging }); + } + + if ( + isDraggedOperation(dragging) && + dragging.layerId === layerId && + columnId !== dragging.columnId + ) { + const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; + const targetColumn = state.layers[layerId].columns[columnId]; + + const isSameGroup = groupId === dragging.groupId; + if (isSameGroup) { + return getDropPropsForSameGroup(targetColumn); + } else if (hasTheSameField(sourceColumn, targetColumn)) { + return; + } else if (filterOperations(sourceColumn)) { + return getDropPropsForCompatibleGroup(targetColumn); + } else { + return getDropPropsFromIncompatibleGroup({ ...props, dragging }); + } + } +} + +function hasTheSameField(sourceColumn: IndexPatternColumn, targetColumn?: IndexPatternColumn) { + return ( + targetColumn && + hasField(targetColumn) && + hasField(sourceColumn) && + targetColumn.sourceField === sourceColumn.sourceField + ); +} + +function getDropPropsForField({ + state, + columnId, + layerId, + dragging, + filterOperations, +}: GetDropProps & { dragging: DraggedField }): DropProps { + const targetColumn = state.layers[layerId].columns[columnId]; + const isTheSameIndexPattern = state.layers[layerId].indexPatternId === dragging.indexPatternId; + const newOperation = getNewOperation(dragging.field, filterOperations, targetColumn); + + if (!!(isTheSameIndexPattern && newOperation)) { + const nextLabel = operationLabels[newOperation].displayName; + + if (!targetColumn) { + return { dropTypes: ['field_add'], nextLabel }; + } else if ( + (hasField(targetColumn) && targetColumn.sourceField !== dragging.field.name) || + !hasField(targetColumn) + ) { + return { + dropTypes: ['field_replace'], + nextLabel, + }; + } + } + return; +} + +function getDropPropsForSameGroup(targetColumn?: IndexPatternColumn): DropProps { + return targetColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; +} + +function getDropPropsForCompatibleGroup(targetColumn?: IndexPatternColumn): DropProps { + return { + dropTypes: targetColumn + ? ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'] + : ['move_compatible', 'duplicate_compatible'], + }; +} + +function getDropPropsFromIncompatibleGroup({ + state, + columnId, + layerId, + dragging, + filterOperations, +}: GetDropProps & { dragging: DraggedOperation }): DropProps { + const targetColumn = state.layers[layerId].columns[columnId]; + const sourceColumn = state.layers[dragging.layerId].columns[dragging.columnId]; + + const layerIndexPattern = state.indexPatterns[state.layers[layerId].indexPatternId]; + const sourceField = getField(sourceColumn, layerIndexPattern); + const newOperationForSource = getNewOperation(sourceField, filterOperations, targetColumn); + + if (newOperationForSource) { + const targetField = getField(targetColumn, layerIndexPattern); + const canSwap = !!getNewOperation(targetField, dragging.filterOperations, sourceColumn); + + return { + dropTypes: targetColumn + ? canSwap + ? ['replace_incompatible', 'replace_duplicate_incompatible', 'swap_incompatible'] + : ['replace_incompatible', 'replace_duplicate_incompatible'] + : ['move_incompatible', 'duplicate_incompatible'], + nextLabel: operationLabels[newOperationForSource].displayName, + }; + } +} diff --git a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts similarity index 69% rename from x-pack/plugins/data_enhanced/public/autocomplete/index.ts rename to x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts index 7910ce3ffb237..07adce49eb90a 100644 --- a/x-pack/plugins/data_enhanced/public/autocomplete/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/index.ts @@ -5,7 +5,5 @@ * 2.0. */ -export { - setupKqlQuerySuggestionProvider, - KUERY_LANGUAGE_NAME, -} from './providers/kql_query_suggestion'; +export { onDrop } from './on_drop_handler'; +export { getDropProps } from './get_drop_props'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts new file mode 100644 index 0000000000000..17b5cbc661ca3 --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -0,0 +1,358 @@ +/* + * 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 { DatasourceDimensionDropHandlerProps, DraggedOperation } from '../../../types'; +import { + insertOrReplaceColumn, + deleteColumn, + getColumnOrder, + reorderByGroups, +} from '../../operations'; +import { mergeLayer } from '../../state_helpers'; +import { isDraggedField } from '../../utils'; +import { getNewOperation, getField } from './get_drop_props'; +import { IndexPatternPrivateState, DraggedField } from '../../types'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; + +type DropHandlerProps = DatasourceDimensionDropHandlerProps & { + droppedItem: T; +}; + +export function onDrop(props: DatasourceDimensionDropHandlerProps) { + const { droppedItem, dropType } = props; + + if (dropType === 'field_add' || dropType === 'field_replace') { + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedField, + }); + } + return operationOnDropMap[dropType]({ + ...props, + droppedItem: droppedItem as DraggedOperation, + }); +} + +const operationOnDropMap = { + field_add: onFieldDrop, + field_replace: onFieldDrop, + + reorder: onReorder, + + move_compatible: (props: DropHandlerProps) => onMoveCompatible(props, true), + replace_compatible: (props: DropHandlerProps) => onMoveCompatible(props, true), + duplicate_compatible: onMoveCompatible, + replace_duplicate_compatible: onMoveCompatible, + + move_incompatible: (props: DropHandlerProps) => onMoveIncompatible(props, true), + replace_incompatible: (props: DropHandlerProps) => + onMoveIncompatible(props, true), + duplicate_incompatible: onMoveIncompatible, + replace_duplicate_incompatible: onMoveIncompatible, + + swap_compatible: onSwapCompatible, + swap_incompatible: onSwapIncompatible, +}; + +function onFieldDrop(props: DropHandlerProps) { + const { + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + groupId, + dimensionGroups, + } = props; + + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const targetColumn = layer.columns[columnId]; + const newOperation = getNewOperation(droppedItem.field, filterOperations, targetColumn); + + if (!isDraggedField(droppedItem) || !newOperation) { + return false; + } + + const newLayer = insertOrReplaceColumn({ + layer, + columnId, + indexPattern, + op: newOperation, + field: droppedItem.field, + visualizationGroups: dimensionGroups, + targetGroup: groupId, + }); + + trackUiEvent('drop_onto_dimension'); + const hasData = Object.values(state.layers).some(({ columns }) => columns.length); + trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); + setState(mergeLayer({ state, layerId, newLayer })); + return true; +} + +function onMoveCompatible( + { + columnId, + setState, + state, + layerId, + droppedItem, + dimensionGroups, + groupId, + }: DropHandlerProps, + shouldDeleteSource?: boolean +) { + const layer = state.layers[layerId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + + const newColumns = { + ...layer.columns, + [columnId]: { ...sourceColumn }, + }; + if (shouldDeleteSource) { + delete newColumns[droppedItem.columnId]; + } + + const newColumnOrder = [...layer.columnOrder]; + + if (shouldDeleteSource) { + const sourceIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const targetIndex = newColumnOrder.findIndex((c) => c === columnId); + + if (targetIndex === -1) { + // for newly created columns, remove the old entry and add the last one to the end + newColumnOrder.splice(sourceIndex, 1); + newColumnOrder.push(columnId); + } else { + // for drop to replace, reuse the same index + newColumnOrder[sourceIndex] = columnId; + } + } else { + // put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array + // then reorder based on dimension groups if necessary + const insertionIndex = sourceColumn.isBucketed + ? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed) + : newColumnOrder.length; + newColumnOrder.splice(insertionIndex, 0, columnId); + } + + const newLayer = { + ...layer, + columnOrder: newColumnOrder, + columns: newColumns, + }; + + const updatedColumnOrder = getColumnOrder(newLayer); + + reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: updatedColumnOrder, + columns: newColumns, + }, + }) + ); + return shouldDeleteSource ? { deleted: droppedItem.columnId } : true; +} + +function onReorder({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) { + function reorderElements(items: string[], dest: string, src: string) { + const result = items.filter((c) => c !== src); + const targetIndex = items.findIndex((c) => c === src); + const sourceIndex = items.findIndex((c) => c === dest); + + const targetPosition = result.indexOf(dest); + result.splice(targetIndex < sourceIndex ? targetPosition + 1 : targetPosition, 0, src); + return result; + } + + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: reorderElements( + state.layers[layerId].columnOrder, + columnId, + droppedItem.columnId + ), + }, + }) + ); + return true; +} + +function onMoveIncompatible( + { + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + dimensionGroups, + groupId, + }: DropHandlerProps, + shouldDeleteSource?: boolean +) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + const targetColumn = layer.columns[columnId] || null; + + const sourceField = getField(sourceColumn, indexPattern); + const newOperation = getNewOperation(sourceField, filterOperations, targetColumn); + if (!newOperation) { + return false; + } + + const modifiedLayer = shouldDeleteSource + ? deleteColumn({ + layer, + columnId: droppedItem.columnId, + indexPattern, + }) + : layer; + + const newLayer = insertOrReplaceColumn({ + layer: modifiedLayer, + columnId, + indexPattern, + op: newOperation, + field: sourceField, + visualizationGroups: dimensionGroups, + targetGroup: groupId, + }); + + trackUiEvent('drop_onto_dimension'); + setState( + mergeLayer({ + state, + layerId, + newLayer, + }) + ); + return shouldDeleteSource ? { deleted: droppedItem.columnId } : true; +} + +function onSwapIncompatible({ + columnId, + setState, + state, + layerId, + droppedItem, + filterOperations, + dimensionGroups, + groupId, +}: DropHandlerProps) { + const layer = state.layers[layerId]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + const sourceColumn = layer.columns[droppedItem.columnId]; + const targetColumn = layer.columns[columnId]; + + const sourceField = getField(sourceColumn, indexPattern); + const targetField = getField(targetColumn, indexPattern); + + const newOperationForSource = getNewOperation(sourceField, filterOperations, targetColumn); + const newOperationForTarget = getNewOperation( + targetField, + droppedItem.filterOperations, + sourceColumn + ); + + if (!newOperationForSource || !newOperationForTarget) { + return false; + } + + const newLayer = insertOrReplaceColumn({ + layer: insertOrReplaceColumn({ + layer, + columnId, + targetGroup: groupId, + indexPattern, + op: newOperationForSource, + field: sourceField, + visualizationGroups: dimensionGroups, + }), + columnId: droppedItem.columnId, + indexPattern, + op: newOperationForTarget, + field: targetField, + visualizationGroups: dimensionGroups, + targetGroup: droppedItem.groupId, + }); + + trackUiEvent('drop_onto_dimension'); + setState( + mergeLayer({ + state, + layerId, + newLayer, + }) + ); + return true; +} + +const swapColumnOrder = (columnOrder: string[], sourceId: string, targetId: string) => { + const newColumnOrder = [...columnOrder]; + const sourceIndex = newColumnOrder.findIndex((c) => c === sourceId); + const targetIndex = newColumnOrder.findIndex((c) => c === targetId); + + newColumnOrder[sourceIndex] = targetId; + newColumnOrder[targetIndex] = sourceId; + + return newColumnOrder; +}; + +function onSwapCompatible({ + columnId, + setState, + state, + layerId, + droppedItem, + dimensionGroups, + groupId, +}: DropHandlerProps) { + const layer = state.layers[layerId]; + const sourceId = droppedItem.columnId; + const targetId = columnId; + + const sourceColumn = { ...layer.columns[sourceId] }; + const targetColumn = { ...layer.columns[targetId] }; + const newColumns = { ...layer.columns }; + newColumns[targetId] = sourceColumn; + newColumns[sourceId] = targetColumn; + + const updatedColumnOrder = swapColumnOrder(layer.columnOrder, sourceId, targetId); + reorderByGroups(dimensionGroups, groupId, updatedColumnOrder, columnId); + + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: updatedColumnOrder, + columns: newColumns, + }, + }) + ); + + return true; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 3b0cb67cbce41..a4a061db04797 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, @@ -13,9 +13,7 @@ import { EuiSwitch, EuiSwitchEvent, EuiSpacer, - EuiPopover, - EuiButtonEmpty, - EuiText, + EuiAccordion, EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; @@ -24,7 +22,7 @@ import { updateColumnParam, isReferenced } from '../../layer_helpers'; import { DataType } from '../../../../types'; import { OperationDefinition } from '../index'; import { FieldBasedIndexPatternColumn } from '../column_types'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import { getEsAggsSuffix, getInvalidFieldMessage } from '../helpers'; import type { IndexPatternLayer } from '../../../types'; @@ -193,8 +191,6 @@ export const termsOperation: OperationDefinition - { updateLayer( @@ -251,71 +247,6 @@ export const termsOperation: OperationDefinition - {!hasRestrictions && ( - - { - setPopoverOpen(!popoverOpen); - }} - > - {i18n.translate('xpack.lens.indexPattern.terms.advancedSettings', { - defaultMessage: 'Advanced', - })} - - } - isOpen={popoverOpen} - closePopover={() => { - setPopoverOpen(false); - }} - > - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'otherBucket', - value: e.target.checked, - }) - ) - } - /> - - - updateLayer( - updateColumnParam({ - layer, - columnId, - paramName: 'missingBucket', - value: e.target.checked, - }) - ) - } - /> - - - - )} @@ -415,6 +346,57 @@ export const termsOperation: OperationDefinition + {!hasRestrictions && ( + <> + + + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'otherBucket', + value: e.target.checked, + }) + ) + } + /> + + + updateLayer( + updateColumnParam({ + layer, + columnId, + paramName: 'missingBucket', + value: e.target.checked, + }) + ) + } + /> + + + )} ); }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index 0ed611e9726ef..97b57dee2fde7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, mount } from 'enzyme'; -import { EuiRange, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { EuiFieldNumber, EuiSelect, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import type { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { createMockedIndexPattern } from '../../../mocks'; -import { ValuesRangeInput } from './values_range_input'; +import { ValuesInput } from './values_input'; import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; @@ -888,7 +888,7 @@ describe('terms', () => { /> ); - expect(instance.find(EuiRange).prop('value')).toEqual('3'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('3'); }); it('should update state with the size value', () => { @@ -904,7 +904,7 @@ describe('terms', () => { ); act(() => { - instance.find(ValuesRangeInput).prop('onChange')!(7); + instance.find(ValuesInput).prop('onChange')!(7); }); expect(updateLayerSpy).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx similarity index 50% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx index 3603188ba30e5..4303695d6e293 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.test.tsx @@ -8,52 +8,50 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; -import { EuiRange } from '@elastic/eui'; -import { ValuesRangeInput } from './values_range_input'; +import { EuiFieldNumber } from '@elastic/eui'; +import { ValuesInput } from './values_input'; jest.mock('react-use/lib/useDebounce', () => (fn: () => void) => fn()); -describe('ValuesRangeInput', () => { - it('should render EuiRange correctly', () => { +describe('Values', () => { + it('should render EuiFieldNumber correctly', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); - expect(instance.find(EuiRange).prop('value')).toEqual('5'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('5'); }); it('should not run onChange function on mount', () => { const onChangeSpy = jest.fn(); - shallow(); + shallow(); expect(onChangeSpy.mock.calls.length).toBe(0); }); it('should run onChange function on update', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '7' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '7' }, + } as React.ChangeEvent); }); - expect(instance.find(EuiRange).prop('value')).toEqual('7'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('7'); expect(onChangeSpy.mock.calls.length).toBe(1); expect(onChangeSpy.mock.calls[0][0]).toBe(7); }); it('should not run onChange function on update when value is out of 1-100 range', () => { const onChangeSpy = jest.fn(); - const instance = shallow(); + const instance = shallow(); act(() => { - instance.find(EuiRange).prop('onChange')!( - { currentTarget: { value: '107' } } as React.ChangeEvent, - true - ); + instance.find(EuiFieldNumber).prop('onChange')!({ + currentTarget: { value: '1007' }, + } as React.ChangeEvent); }); instance.update(); - expect(instance.find(EuiRange).prop('value')).toEqual('107'); + expect(instance.find(EuiFieldNumber).prop('value')).toEqual('1007'); expect(onChangeSpy.mock.calls.length).toBe(1); - expect(onChangeSpy.mock.calls[0][0]).toBe(100); + expect(onChangeSpy.mock.calls[0][0]).toBe(1000); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx similarity index 88% rename from x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx rename to x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx index 068e13429527f..915e67c4eba0b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_range_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/values_input.tsx @@ -7,10 +7,10 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiRange } from '@elastic/eui'; +import { EuiFieldNumber } from '@elastic/eui'; import { useDebounceWithOptions } from '../helpers'; -export const ValuesRangeInput = ({ +export const ValuesInput = ({ value, onChange, }: { @@ -18,7 +18,7 @@ export const ValuesRangeInput = ({ onChange: (value: number) => void; }) => { const MIN_NUMBER_OF_VALUES = 1; - const MAX_NUMBER_OF_VALUES = 100; + const MAX_NUMBER_OF_VALUES = 1000; const [inputValue, setInputValue] = useState(String(value)); @@ -36,13 +36,11 @@ export const ValuesRangeInput = ({ ); return ( - setInputValue(currentTarget.value)} aria-label={i18n.translate('xpack.lens.indexPattern.terms.size', { diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx index 34619ae59ae5f..8796f619277ff 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { render } from 'react-dom'; import { NativeRenderer } from './native_renderer'; import { act } from 'react-dom/test-utils'; @@ -151,4 +151,102 @@ describe('native_renderer', () => { const containerElement: Element = mountpoint.firstElementChild!; expect(containerElement.nodeName).toBe('SPAN'); }); + + it('should properly unmount a react element that is mounted inside the renderer', () => { + let isUnmounted = false; + + function TestComponent() { + useEffect(() => { + return () => { + isUnmounted = true; + }; + }, []); + return <>Hello; + } + + renderAndTriggerHooks( + { + // This render function mimics the most common usage inside Lens + render(, element); + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(isUnmounted).toBe(true); + }); + + it('should call the unmount function provided for non-react elements', () => { + const unmountCallback = jest.fn(); + + renderAndTriggerHooks( + { + return unmountCallback; + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(unmountCallback).toHaveBeenCalled(); + }); + + it('should handle when the mount function is asynchronous without a cleanup fn', () => { + let isUnmounted = false; + + function TestComponent() { + useEffect(() => { + return () => { + isUnmounted = true; + }; + }, []); + return <>Hello; + } + + renderAndTriggerHooks( + { + render(, element); + }} + nativeProps={{}} + />, + mountpoint + ); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(isUnmounted).toBe(true); + }); + + it('should handle when the mount function is asynchronous with a cleanup fn', async () => { + const unmountCallback = jest.fn(); + + renderAndTriggerHooks( + { + return unmountCallback; + }} + nativeProps={{}} + />, + mountpoint + ); + + // Schedule a promise cycle to update the DOM + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Replaces the component at the mountpoint with nothing + renderAndTriggerHooks(<>Empty, mountpoint); + + expect(unmountCallback).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx index 68563e01d7f3f..f0659a130b293 100644 --- a/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx +++ b/x-pack/plugins/lens/public/native_renderer/native_renderer.tsx @@ -5,10 +5,16 @@ * 2.0. */ -import React, { HTMLAttributes } from 'react'; +import React, { HTMLAttributes, useEffect, useRef } from 'react'; +import { unmountComponentAtNode } from 'react-dom'; + +type CleanupCallback = (el: Element) => void; export interface NativeRendererProps extends HTMLAttributes { - render: (domElement: Element, props: T) => void; + render: ( + domElement: Element, + props: T + ) => Promise | CleanupCallback | void; nativeProps: T; tag?: string; } @@ -19,11 +25,42 @@ export interface NativeRendererProps extends HTMLAttributes { * By default the mountpoint element will be a div, this can be changed with the * `tag` prop. * + * If the rendered component tree was using React, we need to clean it up manually, + * otherwise the unmount event never happens. A future addition is for non-React components + * to get cleaned up, which could be added in the future. + * * @param props */ export function NativeRenderer({ render, nativeProps, tag, ...rest }: NativeRendererProps) { + const elementRef = useRef(); + const cleanupRef = useRef<((cleanupElement: Element) => void) | void>(); + useEffect(() => { + return () => { + if (elementRef.current) { + if (cleanupRef.current && typeof cleanupRef.current === 'function') { + cleanupRef.current(elementRef.current); + } + unmountComponentAtNode(elementRef.current); + } + }; + }, []); return React.createElement(tag || 'div', { ...rest, - ref: (el) => el && render(el, nativeProps), + ref: (el) => { + if (el) { + elementRef.current = el; + // Handles the editor frame renderer, which is async + const result = render(el, nativeProps); + if (result instanceof Promise) { + result.then((cleanup) => { + if (typeof cleanup === 'function') { + cleanupRef.current = cleanup; + } + }); + } else if (typeof result === 'function') { + cleanupRef.current = result; + } + } + }, }); } diff --git a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx index c0788e6f67dfe..18c73a01cf784 100644 --- a/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/toolbar_popover.tsx @@ -15,6 +15,7 @@ const typeToIconMap: { [type: string]: string | IconType } = { labels: 'visText', values: 'number', list: 'list', + visualOptions: 'brush', }; export interface ToolbarPopoverProps { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 6c88eb20826bb..3d34d22c5048a 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -17,7 +17,7 @@ import { Datatable, SerializedFieldFormat, } from '../../../../src/plugins/expressions/public'; -import { DragContextState, DragDropIdentifier } from './drag_drop'; +import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import { Document } from './persistence'; import { DateRange } from '../common'; import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; @@ -64,7 +64,7 @@ export interface EditorFrameProps { showNoDataPopover: () => void; } export interface EditorFrameInstance { - mount: (element: Element, props: EditorFrameProps) => void; + mount: (element: Element, props: EditorFrameProps) => Promise; unmount: () => void; } @@ -142,11 +142,16 @@ export type DropType = | 'field_add' | 'field_replace' | 'reorder' - | 'duplicate_in_group' | 'move_compatible' | 'replace_compatible' | 'move_incompatible' - | 'replace_incompatible'; + | 'replace_incompatible' + | 'replace_duplicate_compatible' + | 'duplicate_compatible' + | 'swap_compatible' + | 'replace_duplicate_incompatible' + | 'duplicate_incompatible' + | 'swap_incompatible'; export interface DatasourceSuggestion { state: T; @@ -185,16 +190,28 @@ export interface Datasource { getLayers: (state: T) => string[]; removeColumn: (props: { prevState: T; layerId: string; columnId: string }) => T; - renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps) => void; - renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps) => void; - renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; - renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; + renderDataPanel: ( + domElement: Element, + props: DatasourceDataPanelProps + ) => ((cleanupElement: Element) => void) | void; + renderDimensionTrigger: ( + domElement: Element, + props: DatasourceDimensionTriggerProps + ) => ((cleanupElement: Element) => void) | void; + renderDimensionEditor: ( + domElement: Element, + props: DatasourceDimensionEditorProps + ) => ((cleanupElement: Element) => void) | void; + renderLayerPanel: ( + domElement: Element, + props: DatasourceLayerPanelProps + ) => ((cleanupElement: Element) => void) | void; getDropProps: ( props: DatasourceDimensionDropProps & { groupId: string; dragging: DragContextState['dragging']; } - ) => { dropType: DropType; nextLabel?: string } | undefined; + ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; updateStateOnCloseDimension?: (props: { layerId: string; @@ -295,10 +312,11 @@ export interface DatasourceLayerPanelProps { activeData?: Record; } -export interface DraggedOperation { +export interface DraggedOperation extends DraggingIdentifier { layerId: string; groupId: string; columnId: string; + filterOperations: (operation: OperationMetadata) => boolean; } export function isDraggedOperation( @@ -585,12 +603,18 @@ export interface Visualization { * Popover contents that open when the user clicks the contextMenuIcon. This can be used * for extra configurability, such as for styling the legend or axis */ - renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps) => void; + renderLayerContextMenu?: ( + domElement: Element, + props: VisualizationLayerWidgetProps + ) => ((cleanupElement: Element) => void) | void; /** * Toolbar rendered above the visualization. This is meant to be used to provide chart-level * settings for the visualization. */ - renderToolbar?: (domElement: Element, props: VisualizationToolbarProps) => void; + renderToolbar?: ( + domElement: Element, + props: VisualizationToolbarProps + ) => ((cleanupElement: Element) => void) | void; /** * Visualizations can provide a custom icon which will open a layer-specific popover * If no icon is provided, gear icon is default @@ -620,7 +644,7 @@ export interface Visualization { renderDimensionEditor?: ( domElement: Element, props: VisualizationDimensionEditorProps - ) => void; + ) => ((cleanupElement: Element) => void) | void; /** * The frame will call this function on all visualizations at different times. The diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 982f513ae1019..1130bd7a95d88 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -27,6 +27,9 @@ Object { "type": "expression", }, ], + "curveType": Array [ + "LINEAR", + ], "description": Array [ "", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 0bf5c139e2403..5615a9ac34898 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -24,6 +24,7 @@ import { HorizontalAlignment, ElementClickListener, BrushEndListener, + CurveType, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -179,6 +180,13 @@ export const xyChart: ExpressionFunctionDefinition< help: 'Layers of visual series', multi: true, }, + curveType: { + types: ['string'], + options: ['LINEAR', 'CURVE_MONOTONE_X'], + help: i18n.translate('xpack.lens.xyChart.curveType.help', { + defaultMessage: 'Define how curve type is rendered for a line chart', + }), + }, }, fn(data: LensMultiTable, args: XYArgs) { return { @@ -773,10 +781,17 @@ export function XYChart({ const index = `${layerIndex}-${accessorIndex}`; + const curveType = args.curveType ? CurveType[args.curveType] : undefined; + switch (seriesType) { case 'line': return ( - + ); case 'bar': case 'bar_stacked': @@ -804,11 +819,17 @@ export function XYChart({ key={index} {...seriesProps} fit={isPercentage ? 'zero' : getFitOptions(fittingFunction)} + curve={curveType} /> ); case 'area': return ( - + ); default: return assertNever(seriesType); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 331e27a8efdb0..6a1882edde949 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -148,6 +148,7 @@ export const buildExpression = ( }, ], fittingFunction: [state.fittingFunction || 'None'], + curveType: [state.curveType || 'LINEAR'], axisTitlesVisibilitySettings: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 126be41e7b129..6f1a01acd6e76 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -413,8 +413,11 @@ export interface XYArgs { }; tickLabelsVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_tickLabelsConfig' }; gridlinesVisibilitySettings?: AxesSettingsConfig & { type: 'lens_xy_gridlinesConfig' }; + curveType?: XYCurveType; } +export type XYCurveType = 'LINEAR' | 'CURVE_MONOTONE_X'; + // Persisted parts of the state export interface XYState { preferredSeriesType: SeriesType; @@ -428,6 +431,7 @@ export interface XYState { axisTitlesVisibilitySettings?: AxesSettingsConfig; tickLabelsVisibilitySettings?: AxesSettingsConfig; gridlinesVisibilitySettings?: AxesSettingsConfig; + curveType?: XYCurveType; } export type State = XYState; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx new file mode 100644 index 0000000000000..c37a36a42fa47 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; +import { EuiSwitch } from '@elastic/eui'; +import { LineCurveOption } from './line_curve_option'; + +describe('Line curve option', () => { + it('should show currently selected line curve option', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(true); + }); + + it('should show currently curving disabled', () => { + const component = shallow(); + + expect(component.find(EuiSwitch).prop('checked')).toEqual(false); + }); + + it('should show curving option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(true); + }); + + it('should hide curve option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsCurveStyleToggle"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx new file mode 100644 index 0000000000000..ea0a1553ba5e5 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/line_curve_option.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { XYCurveType } from '../types'; + +export interface LineCurveOptionProps { + /** + * Currently selected value + */ + value?: XYCurveType; + /** + * Callback on display option change + */ + onChange: (id: XYCurveType) => void; + isCurveTypeEnabled?: boolean; +} + +export const LineCurveOption: React.FC = ({ + onChange, + value, + isCurveTypeEnabled = true, +}) => { + return isCurveTypeEnabled ? ( + <> + + { + if (e.target.checked) { + onChange('CURVE_MONOTONE_X'); + } else { + onChange('LINEAR'); + } + }} + data-test-subj="lnsCurveStyleToggle" + /> + + + + ) : null; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx new file mode 100644 index 0000000000000..851b14839d7f7 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_value_option.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallowWithIntl as shallow, mountWithIntl as mount } from '@kbn/test/jest'; +import { EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Missing values option', () => { + it('should show currently selected fitting function', () => { + const component = shallow( + + ); + + expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); + }); + + it('should show currently selected value labels display setting', () => { + const component = mount( + + ); + + expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); + }); + + it('should show display field when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); + }); + + it('should hide in display value label option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); + }); + + it('should show the fitting option when enabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(true); + }); + + it('should hide the fitting option when disabled', () => { + const component = mount( + + ); + + expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx new file mode 100644 index 0000000000000..fb6ecec4d2801 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/missing_values_option.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonGroup, EuiFormRow, EuiIconTip, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { FittingFunction, fittingFunctionDefinitions } from '../fitting_functions'; +import { ValueLabelConfig } from '../types'; + +export interface MissingValuesOptionProps { + valueLabels?: ValueLabelConfig; + fittingFunction?: FittingFunction; + onValueLabelChange: (newMode: ValueLabelConfig) => void; + onFittingFnChange: (newMode: FittingFunction) => void; + isValueLabelsEnabled?: boolean; + isFittingEnabled?: boolean; +} + +const valueLabelsOptions: Array<{ + id: string; + value: 'hide' | 'inside' | 'outside'; + label: string; + 'data-test-subj': string; +}> = [ + { + id: `value_labels_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { + defaultMessage: 'Hide', + }), + 'data-test-subj': 'lnsXY_valueLabels_hide', + }, + { + id: `value_labels_inside`, + value: 'inside', + label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { + defaultMessage: 'Show', + }), + 'data-test-subj': 'lnsXY_valueLabels_inside', + }, +]; + +export const MissingValuesOptions: React.FC = ({ + onValueLabelChange, + onFittingFnChange, + valueLabels, + fittingFunction, + isValueLabelsEnabled = true, + isFittingEnabled = true, +}) => { + const valueLabelsVisibilityMode = valueLabels || 'hide'; + + return ( + <> + {isValueLabelsEnabled && ( + + {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { + defaultMessage: 'Labels', + })} + + } + > + value === valueLabelsVisibilityMode)!.id + } + onChange={(modeId) => { + const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; + onValueLabelChange(newMode); + }} + /> + + )} + {isFittingEnabled && ( + + {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { + defaultMessage: 'Missing values', + })}{' '} + + + } + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={fittingFunction || 'None'} + onChange={(value) => onFittingFnChange(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ )} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx new file mode 100644 index 0000000000000..e7ec395312bff --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.test.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallowWithIntl as shallow } from '@kbn/test/jest'; +import { Position } from '@elastic/charts'; +import { FramePublicAPI } from '../../types'; +import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { State } from '../types'; +import { VisualOptionsPopover } from './visual_options_popover'; +import { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; + +describe('Visual options popover', () => { + let frame: FramePublicAPI; + + function testState(): State { + return { + legend: { isVisible: true, position: Position.Right }, + valueLabels: 'hide', + preferredSeriesType: 'bar', + layers: [ + { + seriesType: 'bar', + layerId: 'first', + splitAccessor: 'baz', + xAccessor: 'foo', + accessors: ['bar'], + }, + ], + }; + } + + beforeEach(() => { + frame = createMockFramePublicAPI(); + frame.datasourceLayers = { + first: createMockDatasource('test').publicAPIMock, + }; + }); + it('should disable the visual options for stacked bar charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should disable the values and fitting for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should not disable the visual options for percentage area charts', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(false); + }); + + it('should disabled the popover if there is histogram series', () => { + // make it detect an histogram series + frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ + isBucketed: true, + scale: 'interval', + }); + const state = testState(); + const component = shallow( + + ); + + expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); + }); + + it('should hide the fitting option for bar series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isFittingEnabled')).toEqual(false); + }); + + it('should show the popover and display field enabled for bar and horizontal_bar series', () => { + const state = testState(); + + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); + + it('should hide in the popover the display option for area and line series', () => { + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(false); + }); + + it('should keep the display option for bar series with multiple layers', () => { + frame.datasourceLayers = { + ...frame.datasourceLayers, + second: createMockDatasource('test').publicAPIMock, + }; + + const state = testState(); + const component = shallow( + + ); + + expect(component.find(MissingValuesOptions).prop('isValueLabelsEnabled')).toEqual(true); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx new file mode 100644 index 0000000000000..fcdef86cc5d0e --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/visual_options_popover/visual_options_popover.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ToolbarPopover } from '../../shared_components'; +import { MissingValuesOptions } from './missing_values_option'; +import { LineCurveOption } from './line_curve_option'; +import { XYState } from '../types'; +import { hasHistogramSeries } from '../state_helpers'; +import { ValidLayer } from '../types'; +import { TooltipWrapper } from '../tooltip_wrapper'; +import { FramePublicAPI } from '../../types'; + +function getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, +}: { + isAreaPercentage: boolean; + isHistogramSeries: boolean; +}): string { + if (isHistogramSeries) { + return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on histograms.', + }); + } + if (isAreaPercentage) { + return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on percentage area charts.', + }); + } + return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', + }); +} + +export interface VisualOptionsPopoverProps { + state: XYState; + setState: (newState: XYState) => void; + datasourceLayers: FramePublicAPI['datasourceLayers']; +} + +export const VisualOptionsPopover: React.FC = ({ + state, + setState, + datasourceLayers, +}) => { + const isAreaPercentage = state?.layers.some( + ({ seriesType }) => seriesType === 'area_percentage_stacked' + ); + + const hasNonBarSeries = state?.layers.some(({ seriesType }) => + ['area_stacked', 'area', 'line'].includes(seriesType) + ); + + const hasBarNotStacked = state?.layers.some(({ seriesType }) => + ['bar', 'bar_horizontal'].includes(seriesType) + ); + + const isHistogramSeries = Boolean( + hasHistogramSeries(state?.layers as ValidLayer[], datasourceLayers) + ); + + const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; + const isFittingEnabled = hasNonBarSeries; + const isCurveTypeEnabled = hasNonBarSeries || isAreaPercentage; + + const valueLabelsDisabledReason = getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, + }); + + const isDisabled = !isValueLabelsEnabled && !isFittingEnabled && !isCurveTypeEnabled; + + return ( + + + { + setState({ + ...state, + curveType: id, + }); + }} + /> + + { + setState({ ...state, valueLabels: newMode }); + }} + onFittingFnChange={(newVal) => { + setState({ ...state, fittingFunction: newVal }); + }} + /> + + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 40ac4958aefb9..f965140a48ca0 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,9 +7,8 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from '@kbn/test/jest'; -import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; +import { EuiButtonGroupProps, EuiButtonGroup } from '@elastic/eui'; import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; -import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; import { State } from './types'; @@ -101,179 +100,6 @@ describe('XY Config panels', () => { }); describe('XyToolbar', () => { - it('should show currently selected fitting function', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiSuperSelect).prop('valueOfSelected')).toEqual('Carry'); - }); - - it('should show currently selected value labels display setting', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.find(EuiButtonGroup).prop('idSelected')).toEqual('value_labels_inside'); - }); - - it('should disable the popover for stacked bar charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disable the popover for percentage area charts', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should disabled the popover if there is histogram series', () => { - // make it detect an histogram series - frame.datasourceLayers.first.getOperationForColumnId = jest.fn().mockReturnValueOnce({ - isBucketed: true, - scale: 'interval', - }); - const state = testState(); - const component = shallow( - - ); - - expect(component.find(ToolbarPopover).prop('isDisabled')).toEqual(true); - }); - - it('should show the popover and display field enabled for bar and horizontal_bar series', () => { - const state = testState(); - - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - - it('should hide the fitting option for bar series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsMissingValuesSelect"]')).toEqual(false); - }); - - it('should hide in the popover the display option for area and line series', () => { - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(false); - }); - - it('should keep the display option for bar series with multiple layers', () => { - frame.datasourceLayers = { - ...frame.datasourceLayers, - second: createMockDatasource('test').publicAPIMock, - }; - - const state = testState(); - const component = shallow( - - ); - - expect(component.exists('[data-test-subj="lnsValueLabelsDisplay"]')).toEqual(true); - }); - it('should disable the popover if there is no right axis', () => { const state = testState(); const component = shallow(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index ac08c55eeadbf..d7868a17bf9db 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -14,15 +14,12 @@ import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, - EuiSuperSelect, EuiFormRow, - EuiText, htmlIdGenerator, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon, - EuiIconTip, } from '@elastic/eui'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { @@ -31,29 +28,17 @@ import { VisualizationDimensionEditorProps, FormatFactory, } from '../types'; -import { - State, - SeriesType, - visualizationTypes, - YAxisMode, - AxesSettingsConfig, - ValidLayer, -} from './types'; -import { - isHorizontalChart, - isHorizontalSeries, - getSeriesColor, - hasHistogramSeries, -} from './state_helpers'; +import { State, SeriesType, visualizationTypes, YAxisMode, AxesSettingsConfig } from './types'; +import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; -import { fittingFunctionDefinitions } from './fitting_functions'; -import { ToolbarPopover, LegendSettingsPopover } from '../shared_components'; +import { LegendSettingsPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { TooltipWrapper } from './tooltip_wrapper'; import { getAxesConfiguration } from './axes_configuration'; import { PalettePicker } from '../shared_components'; import { getAccessorColorConfig, getColorAssignments } from './color_assignment'; import { getSortedAccessors } from './to_expression'; +import { VisualOptionsPopover } from './visual_options_popover/visual_options_popover'; type UnwrapArray = T extends Array ? P : T; type AxesSettingsConfigKeys = keyof AxesSettingsConfig; @@ -92,30 +77,6 @@ const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: }, ]; -const valueLabelsOptions: Array<{ - id: string; - value: 'hide' | 'inside' | 'outside'; - label: string; - 'data-test-subj': string; -}> = [ - { - id: `value_labels_hide`, - value: 'hide', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.auto', { - defaultMessage: 'Hide', - }), - 'data-test-subj': 'lnsXY_valueLabels_hide', - }, - { - id: `value_labels_inside`, - value: 'inside', - label: i18n.translate('xpack.lens.xyChart.valueLabelsVisibility.inside', { - defaultMessage: 'Show', - }), - 'data-test-subj': 'lnsXY_valueLabels_inside', - }, -]; - export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -159,46 +120,9 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } -function getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, -}: { - isAreaPercentage: boolean; - isHistogramSeries: boolean; -}): string { - if (isHistogramSeries) { - return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on histograms.', - }); - } - if (isAreaPercentage) { - return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on percentage area charts.', - }); - } - return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { - defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', - }); -} export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; - const hasNonBarSeries = state?.layers.some(({ seriesType }) => - ['area_stacked', 'area', 'line'].includes(seriesType) - ); - - const hasBarNotStacked = state?.layers.some(({ seriesType }) => - ['bar', 'bar_horizontal'].includes(seriesType) - ); - - const isAreaPercentage = state?.layers.some( - ({ seriesType }) => seriesType === 'area_percentage_stacked' - ); - - const isHistogramSeries = Boolean( - hasHistogramSeries(state?.layers as ValidLayer[], frame.datasourceLayers) - ); - const shouldRotate = state?.layers.length ? isHorizontalChart(state.layers) : false; const axisGroups = getAxesConfiguration(state?.layers, shouldRotate); @@ -267,113 +191,15 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp ? 'hide' : 'show'; - const valueLabelsVisibilityMode = state?.valueLabels || 'hide'; - - const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; - const isFittingEnabled = hasNonBarSeries; - - const valueLabelsDisabledReason = getValueLabelDisableReason({ - isAreaPercentage, - isHistogramSeries, - }); - return ( - - - {isValueLabelsEnabled ? ( - - {i18n.translate('xpack.lens.shared.chartValueLabelVisibilityLabel', { - defaultMessage: 'Labels', - })} - - } - > - value === valueLabelsVisibilityMode)! - .id - } - onChange={(modeId) => { - const newMode = valueLabelsOptions.find(({ id }) => id === modeId)!.value; - setState({ ...state, valueLabels: newMode }); - }} - /> - - ) : null} - {isFittingEnabled ? ( - - {i18n.translate('xpack.lens.xyChart.missingValuesLabel', { - defaultMessage: 'Missing values', - })}{' '} - - - } - > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; - })} - valueOfSelected={state?.fittingFunction || 'None'} - onChange={(value) => setState({ ...state, fittingFunction: value })} - itemLayoutAlign="top" - hasDividers - /> -
- ) : null} -
-
+ '—'; diff --git a/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts new file mode 100644 index 0000000000000..1516ca9128893 --- /dev/null +++ b/x-pack/plugins/lists/public/common/test_utils/kibana_react.mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RecursivePartial } from '@elastic/eui/src/components/common'; + +import { EuiTheme } from '../../../../../../src/plugins/kibana_react/common'; + +export const getMockTheme = (partialTheme: RecursivePartial): EuiTheme => + partialTheme as EuiTheme; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx new file mode 100644 index 0000000000000..8272ca9683a4f --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.stories.tsx @@ -0,0 +1,79 @@ +/* + * 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 { Story, addDecorator } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { AndOrBadge, AndOrBadgeProps } from '.'; + +const sampleText = + 'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.'; + +const mockTheme = getMockTheme({ + darkMode: false, + eui: euiLightVars, +}); + +addDecorator((storyFn) => {storyFn()}); + +export default { + argTypes: { + includeAntennas: { + control: { + type: 'boolean', + }, + description: 'Determines whether extending vertical lines appear extended off of round badge', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + type: { + control: { + options: ['and', 'or'], + type: 'select', + }, + description: '`and | or` - determines text displayed in badge.', + table: { + defaultValue: { + summary: 'and', + }, + }, + type: { + required: true, + }, + }, + }, + component: AndOrBadge, + title: 'AndOrBadge', +}; + +const AndOrBadgeTemplate: Story = (args) => ( + + + + + +

{sampleText}

+
+
+); + +export const Default = AndOrBadgeTemplate.bind({}); +Default.args = { + includeAntennas: false, + type: 'and', +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx new file mode 100644 index 0000000000000..47282d061a65d --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { AndOrBadge } from './'; + +const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); + +describe('AndOrBadge', () => { + test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy(); + }); + + test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeFalsy(); + }); + + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx new file mode 100644 index 0000000000000..a7cbe66c16935 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/index.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { RoundedBadge } from './rounded_badge'; +import { RoundedBadgeAntenna } from './rounded_badge_antenna'; + +export type AndOr = 'and' | 'or'; +export interface AndOrBadgeProps { + type: AndOr; + includeAntennas?: boolean; +} +/** Displays AND / OR in a round badge */ +// This ticket is closed, however, as of 3/23/21 no round badge yet +// Ref: https://github.com/elastic/eui/issues/1655 +export const AndOrBadge = React.memo(({ type, includeAntennas = false }) => { + return includeAntennas ? : ; +}); + +AndOrBadge.displayName = 'AndOrBadge'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx new file mode 100644 index 0000000000000..489d02990b1f4 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.test.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { RoundedBadge } from './rounded_badge'; + +describe('RoundedBadge', () => { + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx new file mode 100644 index 0000000000000..0e8a8ee823593 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBadge } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +import { AndOr } from '.'; + +const RoundBadge = (styled(EuiBadge)` + align-items: center; + border-radius: 100%; + display: inline-flex; + font-size: 9px; + height: 34px; + justify-content: center; + margin: 0 5px 0 5px; + padding: 7px 6px 4px 6px; + user-select: none; + width: 34px; + .euiBadge__content { + position: relative; + top: -1px; + } + .euiBadge__text { + text-overflow: clip; + } +` as unknown) as typeof EuiBadge; + +RoundBadge.displayName = 'RoundBadge'; + +export const RoundedBadge: React.FC<{ type: AndOr }> = ({ type }) => ( + + {type === 'and' ? i18n.AND : i18n.OR} + +); + +RoundedBadge.displayName = 'RoundedBadge'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx new file mode 100644 index 0000000000000..472345b9c9f19 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; + +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { RoundedBadgeAntenna } from './rounded_badge_antenna'; + +const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); + +describe('RoundedBadgeAntenna', () => { + test('it renders top and bottom antenna bars', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy(); + }); + + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx new file mode 100644 index 0000000000000..3e9d850db33b7 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/rounded_badge_antenna.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import { RoundedBadge } from './rounded_badge'; + +import { AndOr } from '.'; + +const antennaStyles = css` + background: ${({ theme }): string => theme.eui.euiColorLightShade}; + position: relative; + width: 2px; + &:after { + background: ${({ theme }): string => theme.eui.euiColorLightShade}; + content: ''; + height: 8px; + right: -4px; + position: absolute; + width: 10px; + clip-path: circle(); + } +`; + +const TopAntenna = styled(EuiFlexItem)` + ${antennaStyles} + &:after { + top: 0; + } +`; +const BottomAntenna = styled(EuiFlexItem)` + ${antennaStyles} + &:after { + bottom: 0; + } +`; + +export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => ( + + + + + + + +); + +RoundedBadgeAntenna.displayName = 'RoundedBadgeAntenna'; diff --git a/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts new file mode 100644 index 0000000000000..0a0a46b224db1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/and_or_badge/translations.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AND = i18n.translate('xpack.lists.andOrBadge.andLabel', { + defaultMessage: 'AND', +}); + +export const OR = i18n.translate('xpack.lists.andOrBadge.orLabel', { + defaultMessage: 'OR', +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md b/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md new file mode 100644 index 0000000000000..fb500ca0761e3 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md @@ -0,0 +1,122 @@ +# Autocomplete Fields + +Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. + +All three of the available components rely on Eui's combo box. + +## useFieldValueAutocomplete + +This hook uses the kibana `services.data.autocomplete.getValueSuggestions()` service to return possible autocomplete fields based on the passed in `indexPattern` and `selectedField`. + +## FieldComponent + +This component can be used to display available indexPattern fields. It requires an indexPattern to be passed in and will show an error state if value is not one of the available indexPattern fields. Users will be able to select only one option. + +The `onChange` handler is passed `IFieldType[]`. + +```js + +``` + +## OperatorComponent + +This component can be used to display available operators. If you want to pass in your own operators, you can use `operatorOptions` prop. If a `operatorOptions` is provided, those will be used and it will ignore any of the built in logic that determines which operators to show. The operators within `operatorOptions` will still need to be of type `OperatorOption`. + +If no `operatorOptions` is provided, then the following behavior is observed: + +- if `selectedField` type is `boolean`, only `is`, `is not`, `exists`, `does not exist` operators will show +- if `selectedField` type is `nested`, only `is` operator will show +- if not one of the above, all operators will show (see `operators.ts`) + +The `onChange` handler is passed `OperatorOption[]`. + +```js + +``` + +## AutocompleteFieldExistsComponent + +This field value component is used when the selected operator is `exists` or `does not exist`. When these operators are selected, they are equivalent to using a wildcard. The combo box will be displayed as disabled. + +```js + +``` + +## AutocompleteFieldListsComponent + +This component can be used to display available large value lists - when operator selected is `is in list` or `is not in list`. It relies on hooks from the `lists` plugin. Users can only select one list and an error is shown if value is not one of available lists. + +The `selectedValue` should be the `id` of the selected list. + +This component relies on `selectedField` to render available lists. The reason being that it relies on the `selectedField` type to determine which lists to show as each large value list has a type as well. So if a user selects a field of type `ip`, it will only display lists of type `ip`. + +The `onChange` handler is passed `ListSchema`. + +```js + +``` + +## AutocompleteFieldMatchComponent + +This component can be used to allow users to select one single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. + +It does some minor validation, assuring that field value is a date if `selectedField` type is `date`, a number if `selectedField` type is `number`, an ip if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string`. + +```js + +``` + +## AutocompleteFieldMatchAnyComponent + +This component can be used to allow users to select multiple values. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own values. + +It does some minor validation, assuring that field values are a date if `selectedField` type is `date`, numbers if `selectedField` type is `number`, ips if `selectedField` type is `ip`. + +The `onChange` handler is passed selected `string[]`. + +```js + +``` \ No newline at end of file diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx new file mode 100644 index 0000000000000..416852b469a79 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { FieldComponent } from './field'; + +describe('FieldComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + + ); + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="fieldAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected field', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('machine.os.raw'); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'machine.os' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { + aggregatable: true, + count: 0, + esTypes: ['text'], + name: 'machine.os', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + ]); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx new file mode 100644 index 0000000000000..b3a5e36f12e40 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +import { getGenericComboBoxProps } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; + +const AS_PLAIN_TEXT = { asPlainText: true }; + +interface OperatorProps { + fieldInputWidth?: number; + fieldTypeFilter?: string[]; + indexPattern: IIndexPattern | undefined; + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + isRequired?: boolean; + onChange: (a: IFieldType[]) => void; + placeholder: string; + selectedField: IFieldType | undefined; +} + +export const FieldComponent: React.FC = ({ + fieldInputWidth, + fieldTypeFilter = [], + indexPattern, + isClearable = false, + isDisabled = false, + isLoading = false, + isRequired = false, + onChange, + placeholder, + selectedField, +}): JSX.Element => { + const [touched, setIsTouched] = useState(false); + + const { availableFields, selectedFields } = useMemo( + () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), + [indexPattern, selectedField, fieldTypeFilter] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + () => getComboBoxProps({ availableFields, selectedFields }), + [availableFields, selectedFields] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: IFieldType[] = newOptions.map( + ({ label }) => availableFields[labels.indexOf(label)] + ); + onChange(newValues); + }, + [availableFields, labels, onChange] + ); + + const handleTouch = useCallback((): void => { + setIsTouched(true); + }, [setIsTouched]); + + const fieldWidth = useMemo(() => { + return fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}; + }, [fieldInputWidth]); + + return ( + + ); +}; + +FieldComponent.displayName = 'Field'; + +interface ComboBoxFields { + availableFields: IFieldType[]; + selectedFields: IFieldType[]; +} + +const getComboBoxFields = ( + indexPattern: IIndexPattern | undefined, + selectedField: IFieldType | undefined, + fieldTypeFilter: string[] +): ComboBoxFields => { + const existingFields = getExistingFields(indexPattern); + const selectedFields = getSelectedFields(selectedField); + const availableFields = getAvailableFields(existingFields, selectedFields, fieldTypeFilter); + + return { availableFields, selectedFields }; +}; + +const getComboBoxProps = (fields: ComboBoxFields): GetGenericComboBoxPropsReturn => { + const { availableFields, selectedFields } = fields; + + return getGenericComboBoxProps({ + getLabel: (field) => field.name, + options: availableFields, + selectedOptions: selectedFields, + }); +}; + +const getExistingFields = (indexPattern: IIndexPattern | undefined): IFieldType[] => { + return indexPattern != null ? indexPattern.fields : []; +}; + +const getSelectedFields = (selectedField: IFieldType | undefined): IFieldType[] => { + return selectedField ? [selectedField] : []; +}; + +const getAvailableFields = ( + existingFields: IFieldType[], + selectedFields: IFieldType[], + fieldTypeFilter: string[] +): IFieldType[] => { + const fieldsByName = new Map(); + + existingFields.forEach((f) => fieldsByName.set(f.name, f)); + selectedFields.forEach((f) => fieldsByName.set(f.name, f)); + + const uniqueFields = Array.from(fieldsByName.values()); + + if (fieldTypeFilter.length > 0) { + return uniqueFields.filter(({ type }) => fieldTypeFilter.includes(type)); + } + + return uniqueFields; +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx new file mode 100644 index 0000000000000..b6300581f12dd --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { AutocompleteFieldExistsComponent } from './field_value_exists'; + +describe('AutocompleteFieldExistsComponent', () => { + test('it renders field disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox existsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx new file mode 100644 index 0000000000000..ff70204e53483 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + +const NO_OPTIONS_FOR_EXIST: EuiComboBoxOptionOption[] = []; + +interface AutocompleteFieldExistsProps { + placeholder: string; + rowLabel?: string; +} + +export const AutocompleteFieldExistsComponent: React.FC = ({ + placeholder, + rowLabel, +}): JSX.Element => ( + + + +); + +AutocompleteFieldExistsComponent.displayName = 'AutocompleteFieldExists'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx new file mode 100644 index 0000000000000..a5588b36aae03 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx @@ -0,0 +1,224 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/react'; + +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { ListSchema } from '../../../../common'; +import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { DATE_NOW, IMMUTABLE, VERSION } from '../../../../../lists/common/constants.mock'; + +import { AutocompleteFieldListsComponent } from './field_value_lists'; + +const mockKibanaHttpService = coreMock.createStart().http; + +const mockStart = jest.fn(); +const mockKeywordList: ListSchema = { + ...getListResponseMock(), + id: 'keyword_list', + name: 'keyword list', + type: 'keyword', +}; +const mockResult = { ...getFoundListSchemaMock() }; +mockResult.data = [...mockResult.data, mockKeywordList]; +jest.mock('../../..', () => { + const originalModule = jest.requireActual('../../..'); + + return { + ...originalModule, + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + useFindLists: () => ({ + error: undefined, + loading: false, + result: mockResult, + start: mockStart.mockReturnValue(mockResult), + }), + }; +}); + +describe('AutocompleteFieldListsComponent', () => { + test('it renders disabled if "isDisabled" is true', async () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', async () => { + const wrapper = mount( + + ); + + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( + wrapper + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', async () => { + const wrapper = mount( + + ); + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); + }); + + test('it correctly displays lists that match the selected "keyword" field esType', () => { + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'keyword list' }]); + }); + + test('it correctly displays lists that match the selected "ip" field esType', () => { + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click'); + + expect( + wrapper + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); + }); + + test('it correctly displays selected list', async () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] EuiComboBoxPill`) + .at(0) + .text() + ).toEqual('some name'); + }); + + test('it invokes "onChange" when option selected', async () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'some name' }]); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ + _version: undefined, + created_at: DATE_NOW, + created_by: 'some user', + description: 'some description', + deserializer: undefined, + id: 'some-list-id', + immutable: IMMUTABLE, + meta: {}, + name: 'some name', + serializer: undefined, + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: DATE_NOW, + updated_by: 'some user', + version: VERSION, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx new file mode 100644 index 0000000000000..3d910403d4843 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { HttpStart } from 'kibana/public'; + +import { ListSchema } from '../../../../common'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { useFindLists } from '../../..'; + +import { filterFieldToList, getGenericComboBoxProps } from './helpers'; +import * as i18n from './translations'; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldListsProps { + httpService: HttpStart; + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + onChange: (arg: ListSchema) => void; + placeholder: string; + rowLabel?: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; +} + +export const AutocompleteFieldListsComponent: React.FC = ({ + httpService, + isClearable = false, + isDisabled = false, + isLoading = false, + onChange, + placeholder, + rowLabel, + selectedField, + selectedValue, +}): JSX.Element => { + const [error, setError] = useState(undefined); + const [lists, setLists] = useState([]); + const { loading, result, start } = useFindLists(); + const getLabel = useCallback(({ name }) => name, []); + + const optionsMemo = useMemo(() => filterFieldToList(lists, selectedField), [ + lists, + selectedField, + ]); + const selectedOptionsMemo = useMemo(() => { + if (selectedValue != null) { + const list = lists.filter(({ id }) => id === selectedValue); + return list ?? []; + } else { + return []; + } + }, [selectedValue, lists]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + () => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + onChange(newValue ?? ''); + }, + [labels, optionsMemo, onChange] + ); + + const setIsTouchedValue = useCallback((): void => { + setError(selectedValue == null ? i18n.FIELD_REQUIRED_ERR : undefined); + }, [selectedValue]); + + useEffect(() => { + if (result != null) { + setLists(result.data); + } + }, [result]); + + useEffect(() => { + if (selectedField != null && httpService != null) { + start({ + http: httpService, + pageIndex: 1, + pageSize: 500, + }); + } + }, [selectedField, start, httpService]); + + const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]); + + return ( + + + + ); +}; + +AutocompleteFieldListsComponent.displayName = 'AutocompleteFieldList'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx new file mode 100644 index 0000000000000..c1ffb008e8563 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx @@ -0,0 +1,443 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiSuperSelect } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { AutocompleteFieldMatchComponent } from './field_value_match'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; + +jest.mock('./hooks/use_field_value_autocomplete'); + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +describe('AutocompleteFieldMatchComponent', () => { + let wrapper: ReactWrapper; + + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders row label if one passed in', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchLabel"] label').at(0).text() + ).toEqual('Row Label'); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] input').prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click'); + expect( + wrapper + .find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatch-optionsList"]') + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="comboBoxInput"]') + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatch"] EuiComboBoxPill').at(0).text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith('126.45.211.34'); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith('value 1'); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + }); + + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: '', + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + operatorType: 'match', + query: 'value 1', + selectedField: getField('machine.os.raw'), + }); + }); + + describe('boolean type', () => { + const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + false, + [], + valueSuggestionsMock, + ]); + }); + + test('it displays only two options - "true" or "false"', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').at(0).prop('options') + ).toEqual([ + { + inputDisplay: 'true', + value: 'true', + }, + { + inputDisplay: 'false', + value: 'false', + }, + ]); + }); + + test('it invokes "onChange" with "true" when selected', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiSuperSelect).props() as unknown) as { + onChange: (a: string) => void; + }).onChange('true'); + + expect(mockOnChange).toHaveBeenCalledWith('true'); + }); + + test('it invokes "onChange" with "false" when selected', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiSuperSelect).props() as unknown) as { + onChange: (a: string) => void; + }).onChange('false'); + + expect(mockOnChange).toHaveBeenCalledWith('false'); + }); + }); + + describe('number type', () => { + const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + false, + [], + valueSuggestionsMock, + ]); + }); + + test('it number input when field type is number', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valueAutocompleteFieldMatchNumber"]').exists() + ).toBeTruthy(); + }); + + test('it invokes "onChange" with numeric value when inputted', () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + wrapper + .find('[data-test-subj="valueAutocompleteFieldMatchNumber"] input') + .at(0) + .simulate('change', { target: { value: '8' } }); + + expect(mockOnChange).toHaveBeenCalledWith('8'); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx new file mode 100644 index 0000000000000..a0994871808d1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx @@ -0,0 +1,300 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldNumber, + EuiFormRow, + EuiSuperSelect, +} from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { OperatorTypeEnum } from '../../../../common'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; + +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +const BOOLEAN_OPTIONS = [ + { inputDisplay: 'true', value: 'true' }, + { inputDisplay: 'false', value: 'false' }, +]; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldMatchProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string | undefined; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + isRequired?: boolean; + fieldInputWidth?: number; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string) => void; + onError?: (arg: boolean) => void; +} + +export const AutocompleteFieldMatchComponent: React.FC = ({ + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + fieldInputWidth, + onChange, + onError, + autocompleteService, +}): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.MATCH, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue != null && selectedValue.trim() !== '' + ? uniq([valueAsStr, ...suggestions]) + : suggestions; + }, [suggestions, selectedValue]); + const selectedOptionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? [valueAsStr] : []; + }, [selectedValue]); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + onChange(newValue ?? ''); + }, + [handleError, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string): void => { + if (searchVal !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched] + ); + + const handleCreateOption = useCallback( + (option: string): boolean | undefined => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange(option); + return undefined; + } + }, + [isRequired, onChange, selectedField, touched, handleError] + ); + + const handleNonComboBoxInputChange = useCallback( + (event: React.ChangeEvent): void => { + const newValue = event.target.value; + onChange(newValue); + }, + [onChange] + ); + + const handleBooleanInputChange = useCallback( + (newOption: string): void => { + onChange(newOption); + }, + [onChange] + ); + + const setIsTouchedValue = useCallback((): void => { + setIsTouched(true); + + const err = paramIsValid(selectedValue, selectedField, isRequired, true); + handleError(err); + }, [setIsTouched, handleError, selectedValue, selectedField, isRequired]); + + const inputPlaceholder = useMemo((): string => { + if (isLoading || isLoadingSuggestions) { + return i18n.LOADING; + } else if (selectedField == null) { + return i18n.SELECT_FIELD_FIRST; + } else { + return placeholder; + } + }, [isLoading, selectedField, isLoadingSuggestions, placeholder]); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); + + const fieldInputWidths = useMemo( + () => (fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}), + [fieldInputWidth] + ); + + useEffect((): void => { + setError(undefined); + if (onError != null) { + onError(false); + } + }, [selectedField, onError]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + fieldInputWidths, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + ]); + + if (!isSuggestingValues && selectedField != null) { + switch (selectedField.type) { + case 'number': + return ( + + 0 + ? parseFloat(selectedValue) + : selectedValue ?? '' + } + onChange={handleNonComboBoxInputChange} + data-test-subj="valueAutocompleteFieldMatchNumber" + style={fieldInputWidths} + fullWidth + /> + + ); + case 'boolean': + return ( + + + + ); + default: + return defaultInput; + } + } else { + return defaultInput; + } +}; + +AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx new file mode 100644 index 0000000000000..8aa1f18b695a0 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +import { + fields, + getField, +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; + +import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +jest.mock('./hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldMatchAnyComponent', () => { + let wrapper: ReactWrapper; + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatchAny-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="comboBoxInput"]`) + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] EuiComboBoxPill`).at(0).text() + ).toEqual('126.45.211.34'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onCreateOption: (a: string) => void; + }).onCreateOption('126.45.211.34'); + + expect(mockOnChange).toHaveBeenCalledWith(['126.45.211.34']); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith(['value 1']); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ((wrapper.find(EuiComboBox).props() as unknown) as { + onSearchChange: (a: string) => void; + }).onSearchChange('value 1'); + }); + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: [], + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + operatorType: 'match_any', + query: 'value 1', + selectedField: getField('machine.os.raw'), + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx new file mode 100644 index 0000000000000..08958f6d99aab --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { uniq } from 'lodash'; + +import { OperatorTypeEnum } from '../../../../common'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; + +import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; +import { GetGenericComboBoxPropsReturn } from './types'; +import * as i18n from './translations'; + +interface AutocompleteFieldMatchAnyProps { + placeholder: string; + selectedField: IFieldType | undefined; + selectedValue: string[]; + indexPattern: IIndexPattern | undefined; + isLoading: boolean; + isDisabled: boolean; + isClearable: boolean; + isRequired?: boolean; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string[]) => void; + onError?: (arg: boolean) => void; +} + +export const AutocompleteFieldMatchAnyComponent: React.FC = ({ + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + onChange, + onError, + autocompleteService, +}): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.MATCH_ANY, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo( + (): string[] => (selectedValue ? uniq([...selectedValue, ...suggestions]) : suggestions), + [suggestions, selectedValue] + ); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedValue, + }), + [optionsMemo, selectedValue, getLabel] + ); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: string[] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + onChange(newValues); + }, + [handleError, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string) => { + if (searchVal === '') { + handleError(undefined); + } + + if (searchVal !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched] + ); + + const handleCreateOption = useCallback( + (option: string): boolean => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange([...(selectedValue || []), option]); + return true; + } + }, + [handleError, isRequired, onChange, selectedField, selectedValue, touched] + ); + + const setIsTouchedValue = useCallback((): void => { + handleError(selectedComboOptions.length === 0 ? i18n.FIELD_REQUIRED_ERR : undefined); + setIsTouched(true); + }, [setIsTouched, handleError, selectedComboOptions]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + ]); + + if (!isSuggestingValues && selectedField != null) { + switch (selectedField.type) { + case 'number': + return ( + + + + ); + default: + return defaultInput; + } + } + + return defaultInput; +}; + +AutocompleteFieldMatchAnyComponent.displayName = 'AutocompleteFieldMatchAny'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts new file mode 100644 index 0000000000000..2fed462974a26 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts @@ -0,0 +1,388 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../common'; + +import * as i18n from './translations'; +import { + EXCEPTION_OPERATORS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOperator, +} from './operators'; +import { + checkEmptyValue, + filterFieldToList, + getGenericComboBoxProps, + getOperators, + paramIsValid, + typeMatch, +} from './helpers'; + +describe('helpers', () => { + // @ts-ignore + moment.suppressDeprecationWarnings = true; + describe('#getOperators', () => { + test('it returns "isOperator" if passed in field is "undefined"', () => { + const operator = getOperators(undefined); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns expected operators when field type is "boolean"', () => { + const operator = getOperators(getField('ssl')); + + expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]); + }); + + test('it returns "isOperator" when field type is "nested"', () => { + const operator = getOperators({ + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'nestedField' } }, + type: 'nested', + }); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns all operator types when field type is not null, boolean, or nested', () => { + const operator = getOperators(getField('machine.os.raw')); + + expect(operator).toEqual(EXCEPTION_OPERATORS); + }); + }); + + describe('#checkEmptyValue', () => { + test('returns no errors if no field has been selected', () => { + const isValid = checkEmptyValue('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = checkEmptyValue('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns null if input value is not empty string or undefined', () => { + const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true); + + expect(isValid).toBeNull(); + }); + }); + + describe('#paramIsValid', () => { + test('returns no errors if no field has been selected', () => { + const isValid = paramIsValid('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = paramIsValid('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type date and value is valid', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + false, + true + ); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if filed is of type date and value is not valid', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true); + + expect(isValid).toEqual(i18n.DATE_ERR); + }); + + test('returns no errors if field is of type number and value is an integer', () => { + const isValid = paramIsValid('4', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a float', () => { + const isValid = paramIsValid('4.3', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a long', () => { + const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if field is of type number and value is "hello"', () => { + const isValid = paramIsValid('hello', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); + + test('returns errors if field is of type number and value is "123abc"', () => { + const isValid = paramIsValid('123abc', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); + }); + + describe('#getGenericComboBoxProps', () => { + test('it returns empty arrays if "options" is empty array', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: [], + selectedOptions: ['option1'], + }); + + expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); + }); + + test('it returns formatted props if "options" array is not empty', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: [], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it does not return "selectedOptions" items that do not appear in "options"', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option4'], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it return "selectedOptions" items that do appear in "options"', () => { + const result = getGenericComboBoxProps({ + getLabel: (t: string) => t, + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option2'], + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [ + { + label: 'option2', + }, + ], + }); + }); + }); + + describe('#typeMatch', () => { + test('ip -> ip is true', () => { + expect(typeMatch('ip', 'ip')).toEqual(true); + }); + + test('keyword -> keyword is true', () => { + expect(typeMatch('keyword', 'keyword')).toEqual(true); + }); + + test('text -> text is true', () => { + expect(typeMatch('text', 'text')).toEqual(true); + }); + + test('ip_range -> ip is true', () => { + expect(typeMatch('ip_range', 'ip')).toEqual(true); + }); + + test('date_range -> date is true', () => { + expect(typeMatch('date_range', 'date')).toEqual(true); + }); + + test('double_range -> double is true', () => { + expect(typeMatch('double_range', 'double')).toEqual(true); + }); + + test('float_range -> float is true', () => { + expect(typeMatch('float_range', 'float')).toEqual(true); + }); + + test('integer_range -> integer is true', () => { + expect(typeMatch('integer_range', 'integer')).toEqual(true); + }); + + test('long_range -> long is true', () => { + expect(typeMatch('long_range', 'long')).toEqual(true); + }); + + test('ip -> date is false', () => { + expect(typeMatch('ip', 'date')).toEqual(false); + }); + + test('long -> float is false', () => { + expect(typeMatch('long', 'float')).toEqual(false); + }); + + test('integer -> long is false', () => { + expect(typeMatch('integer', 'long')).toEqual(false); + }); + }); + + describe('#filterFieldToList', () => { + test('it returns empty array if given a undefined for field', () => { + const filter = filterFieldToList([], undefined); + expect(filter).toEqual([]); + }); + + test('it returns empty array if filed does not contain esTypes', () => { + const field: IFieldType = { name: 'some-name', type: 'some-type' }; + const filter = filterFieldToList([], field); + expect(filter).toEqual([]); + }); + + test('it returns single filtered list of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of ip -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of keyword -> keyword', () => { + const field: IFieldType = { esTypes: ['keyword'], name: 'some-name', type: 'keyword' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of text -> text', () => { + const field: IFieldType = { esTypes: ['text'], name: 'some-name', type: 'text' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns 2 filtered lists of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1, listItem2]; + expect(filter).toEqual(expected); + }); + + test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1]; + expect(filter).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts new file mode 100644 index 0000000000000..4f25bec3b38dc --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts @@ -0,0 +1,175 @@ +/* + * 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 dateMath from '@elastic/datemath'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { ListSchema, Type } from '../../../../common'; +import { IFieldType } from '../../../../../../../src/plugins/data/common'; + +import { + EXCEPTION_OPERATORS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOperator, +} from './operators'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; +import * as i18n from './translations'; + +/** + * Returns the appropriate operators given a field type + * + * @param field IFieldType selected field + * + */ +export const getOperators = (field: IFieldType | undefined): OperatorOption[] => { + if (field == null) { + return [isOperator]; + } else if (field.type === 'boolean') { + return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; + } else if (field.type === 'nested') { + return [isOperator]; + } else { + return EXCEPTION_OPERATORS; + } +}; + +/** + * Determines if empty value is ok + * + * @param param the value being checked + * @param field the selected field + * @param isRequired whether or not an empty value is allowed + * @param touched has field been touched by user + * @returns undefined if valid, string with error message if invalid, + * null if no checks matched + */ +export const checkEmptyValue = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined | null => { + if (isRequired && touched && (param == null || param.trim() === '')) { + return i18n.FIELD_REQUIRED_ERR; + } + + if ( + field == null || + (isRequired && !touched) || + (!isRequired && (param == null || param === '')) + ) { + return undefined; + } + + return null; +}; + +/** + * Very basic validation for values + * + * @param param the value being checked + * @param field the selected field + * @param isRequired whether or not an empty value is allowed + * @param touched has field been touched by user + * @returns undefined if valid, string with error message if invalid + */ +export const paramIsValid = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined => { + if (field == null) { + return undefined; + } + + const emptyValueError = checkEmptyValue(param, field, isRequired, touched); + if (emptyValueError !== null) { + return emptyValueError; + } + + switch (field.type) { + case 'date': + const moment = dateMath.parse(param ?? ''); + const isDate = Boolean(moment && moment.isValid()); + return isDate ? undefined : i18n.DATE_ERR; + case 'number': + const isNum = param != null && param.trim() !== '' && !isNaN(+param); + return isNum ? undefined : i18n.NUMBER_ERR; + default: + return undefined; + } +}; + +/** + * Determines the options, selected values and option labels for EUI combo box + * + * @param options options user can select from + * @param selectedOptions user selection if any + * @param getLabel helper function to know which property to use for labels + */ +export const getGenericComboBoxProps = ({ + getLabel, + options, + selectedOptions, +}: { + getLabel: (value: T) => string; + options: T[]; + selectedOptions: T[]; +}): GetGenericComboBoxPropsReturn => { + const newLabels = options.map(getLabel); + const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); + const newSelectedComboOptions = selectedOptions + .map(getLabel) + .filter((option) => { + return newLabels.indexOf(option) !== -1; + }) + .map((option) => { + return newComboOptions[newLabels.indexOf(option)]; + }); + + return { + comboOptions: newComboOptions, + labels: newLabels, + selectedComboOptions: newSelectedComboOptions, + }; +}; + +/** + * Given an array of lists and optionally a field this will return all + * the lists that match against the field based on the types from the field + * @param lists The lists to match against the field + * @param field The field to check against the list to see if they are compatible + */ +export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { + if (field != null) { + const { esTypes = [] } = field; + return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType))); + } else { + return []; + } +}; + +/** + * Given an input list type and a string based ES type this will match + * if they're exact or if they are compatible with a range + * @param type The type to match against the esType + * @param esType The ES type to match with + */ +export const typeMatch = (type: Type, esType: string): boolean => { + return ( + type === esType || + (type === 'ip_range' && esType === 'ip') || + (type === 'date_range' && esType === 'date') || + (type === 'double_range' && esType === 'double') || + (type === 'float_range' && esType === 'float') || + (type === 'integer_range' && esType === 'integer') || + (type === 'long_range' && esType === 'long') + ); +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts new file mode 100644 index 0000000000000..4e3fb2179d786 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub'; +import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { OperatorTypeEnum } from '../../../../../common'; +import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; + +import { + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn, + useFieldValueAutocomplete, +} from './use_field_value_autocomplete'; + +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); + +jest.mock('../../../../../../../../src/plugins/kibana_react/public'); + +describe('useFieldValueAutocomplete', () => { + const onErrorMock = jest.fn(); + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + afterEach(() => { + onErrorMock.mockClear(); + getValueSuggestionsMock.mockClear(); + }); + + test('initializes hook', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: undefined, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: undefined, + }) + ); + await waitForNextUpdate(); + + expect(result.current).toEqual([false, true, [], result.current[3]]); + }); + }); + + test('does not call autocomplete service if "operatorType" is "exists"', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: getField('machine.os'), + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "selectedField" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: undefined, + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('does not call autocomplete service if "indexPattern" is undefined', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: undefined, + operatorType: OperatorTypeEnum.EXISTS, + query: '', + selectedField: getField('machine.os'), + }) + ); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('it uses full path name for nested fields to fetch suggestions', async () => { + const suggestionsMock = jest.fn().mockResolvedValue([]); + + await act(async () => { + const { signal } = new AbortController(); + const { waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: suggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: { ...getField('nestedField.child'), name: 'child' }, + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(suggestionsMock).toHaveBeenCalledWith({ + field: { ...getField('nestedField.child'), name: 'nestedField.child' }, + indexPattern: { + fields: [ + { + aggregatable: true, + esTypes: ['integer'], + filterable: true, + name: 'response', + searchable: true, + type: 'number', + }, + ], + id: '1234', + title: 'logstash-*', + }, + query: '', + signal, + }); + }); + }); + + test('returns "isSuggestingValues" of false if field type is boolean', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('ssl'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; + + expect(getValueSuggestionsMock).not.toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns "isSuggestingValues" of false to note that autocomplete service is not in use if no autocomplete suggestions available', async () => { + const suggestionsMock = jest.fn().mockResolvedValue([]); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: suggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('bytes'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; + + expect(suggestionsMock).toHaveBeenCalled(); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns suggestions', async () => { + await act(async () => { + const { signal } = new AbortController(); + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('@tags'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + true, + ['value 1', 'value 2'], + result.current[3], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledWith({ + field: getField('@tags'), + indexPattern: stubIndexPatternWithFields, + query: '', + signal, + }); + expect(result.current).toEqual(expectedResult); + }); + }); + + test('returns new suggestions on subsequent calls', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseFieldValueAutocompleteProps, + UseFieldValueAutocompleteReturn + >(() => + useFieldValueAutocomplete({ + autocompleteService: { + ...autocompleteStartMock, + getValueSuggestions: getValueSuggestionsMock, + }, + fieldValue: '', + indexPattern: stubIndexPatternWithFields, + operatorType: OperatorTypeEnum.MATCH, + query: '', + selectedField: getField('@tags'), + }) + ); + // Note: initial `waitForNextUpdate` is hook initialization + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current[3]).not.toBeNull(); + + // Added check for typescripts sake, if null, + // would not reach below logic as test would stop above + if (result.current[3] != null) { + result.current[3]({ + fieldSelected: getField('@tags'), + patterns: stubIndexPatternWithFields, + searchQuery: '', + value: 'hello', + }); + } + + await waitForNextUpdate(); + + const expectedResult: UseFieldValueAutocompleteReturn = [ + false, + true, + ['value 1', 'value 2'], + result.current[3], + ]; + + expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2); + expect(result.current).toEqual(expectedResult); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts new file mode 100644 index 0000000000000..6c6198ac55a0f --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import { debounce } from 'lodash'; + +import { AutocompleteStart } from '../../../../../../../../src/plugins/data/public'; +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { OperatorTypeEnum } from '../../../../../common'; + +interface FuncArgs { + fieldSelected: IFieldType | undefined; + patterns: IIndexPattern | undefined; + searchQuery: string; + value: string | string[] | undefined; +} + +type Func = (args: FuncArgs) => void; + +export type UseFieldValueAutocompleteReturn = [boolean, boolean, string[], Func | null]; + +export interface UseFieldValueAutocompleteProps { + autocompleteService: AutocompleteStart; + fieldValue: string | string[] | undefined; + indexPattern: IIndexPattern | undefined; + operatorType: OperatorTypeEnum; + query: string; + selectedField: IFieldType | undefined; +} +/** + * Hook for using the field value autocomplete service + * + */ +export const useFieldValueAutocomplete = ({ + selectedField, + operatorType, + fieldValue, + query, + indexPattern, + autocompleteService, +}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => { + const [isLoading, setIsLoading] = useState(false); + const [isSuggestingValues, setIsSuggestingValues] = useState(true); + const [suggestions, setSuggestions] = useState([]); + const updateSuggestions = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchSuggestions = debounce( + async ({ fieldSelected, patterns, searchQuery }: FuncArgs) => { + try { + if (isSubscribed) { + if (fieldSelected == null || patterns == null) { + return; + } + + if (fieldSelected.type === 'boolean') { + setIsSuggestingValues(false); + return; + } + + setIsLoading(true); + + const field = + fieldSelected.subType != null && fieldSelected.subType.nested != null + ? { + ...fieldSelected, + name: `${fieldSelected.subType.nested.path}.${fieldSelected.name}`, + } + : fieldSelected; + + const newSuggestions = await autocompleteService.getValueSuggestions({ + field, + indexPattern: patterns, + query: searchQuery, + signal: abortCtrl.signal, + }); + + if (newSuggestions.length === 0) { + setIsSuggestingValues(false); + } + + setIsLoading(false); + setSuggestions([...newSuggestions]); + } + } catch (error) { + if (isSubscribed) { + setSuggestions([]); + setIsLoading(false); + } + } + }, + 500 + ); + + if (operatorType !== OperatorTypeEnum.EXISTS) { + fetchSuggestions({ + fieldSelected: selectedField, + patterns: indexPattern, + searchQuery: query, + value: fieldValue, + }); + } + + updateSuggestions.current = fetchSuggestions; + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [selectedField, operatorType, fieldValue, indexPattern, query, autocompleteService]); + + return [isLoading, isSuggestingValues, suggestions, updateSuggestions.current]; +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx new file mode 100644 index 0000000000000..1623683f25ed5 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { AutocompleteFieldExistsComponent } from './field_value_exists'; +export { AutocompleteFieldListsComponent } from './field_value_lists'; +export { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; +export { AutocompleteFieldMatchComponent } from './field_value_match'; +export { FieldComponent } from './field'; +export { OperatorComponent } from './operator'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx new file mode 100644 index 0000000000000..1d033272197ca --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; + +import { OperatorComponent } from './operator'; +import { isNotOperator, isOperator } from './operators'; + +describe('OperatorComponent', () => { + test('it renders disabled if "isDisabled" is true', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] input`).prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + const wrapper = mount( + + ); + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] button`).at(0).simulate('click'); + expect( + wrapper + .find(`EuiComboBoxOptionsList[data-test-subj="operatorAutocompleteComboBox-optionsList"]`) + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); + }); + + test('it displays "operatorOptions" if param is passed in with items', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is not' }]); + }); + + test('it does not display "operatorOptions" if param is passed in with no items', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { + label: 'is', + }, + { + label: 'is not', + }, + { + label: 'is one of', + }, + { + label: 'is not one of', + }, + { + label: 'exists', + }, + { + label: 'does not exist', + }, + { + label: 'is in list', + }, + { + label: 'is not in list', + }, + ]); + }); + + test('it correctly displays selected operator', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() + ).toEqual('is'); + }); + + test('it only displays subset of operators if field type is nested', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([{ label: 'is' }]); + }); + + test('it only displays subset of operators if field type is boolean', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { label: 'is' }, + { label: 'is not' }, + { label: 'exists' }, + { label: 'does not exist' }, + ]); + }); + + test('it invokes "onChange" when option selected', () => { + const mockOnChange = jest.fn(); + const wrapper = mount( + + ); + + ((wrapper.find(EuiComboBox).props() as unknown) as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + }).onChange([{ label: 'is not' }]); + + expect(mockOnChange).toHaveBeenCalledWith([ + { message: 'is not', operator: 'excluded', type: 'match', value: 'is_not' }, + ]); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx new file mode 100644 index 0000000000000..7fc221c5a097c --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useMemo } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; + +import { getGenericComboBoxProps, getOperators } from './helpers'; +import { GetGenericComboBoxPropsReturn, OperatorOption } from './types'; + +const AS_PLAIN_TEXT = { asPlainText: true }; + +interface OperatorState { + isClearable: boolean; + isDisabled: boolean; + isLoading: boolean; + onChange: (arg: OperatorOption[]) => void; + operator: OperatorOption; + operatorInputWidth?: number; + operatorOptions?: OperatorOption[]; + placeholder: string; + selectedField: IFieldType | undefined; +} + +export const OperatorComponent: React.FC = ({ + isClearable = false, + isDisabled = false, + isLoading = false, + onChange, + operator, + operatorOptions, + operatorInputWidth = 150, + placeholder, + selectedField, +}): JSX.Element => { + const getLabel = useCallback(({ message }): string => message, []); + const optionsMemo = useMemo( + (): OperatorOption[] => + operatorOptions != null && operatorOptions.length > 0 + ? operatorOptions + : getOperators(selectedField), + [operatorOptions, selectedField] + ); + const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [ + operator, + ]); + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const newValues: OperatorOption[] = newOptions.map( + ({ label }) => optionsMemo[labels.indexOf(label)] + ); + onChange(newValues); + }, + [labels, onChange, optionsMemo] + ); + + const inputWidth = useMemo(() => { + return { width: `${operatorInputWidth}px` }; + }, [operatorInputWidth]); + + return ( + + ); +}; + +OperatorComponent.displayName = 'Operator'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts new file mode 100644 index 0000000000000..551dfcb61e3ad --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/operators.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; + +import { OperatorOption } from './types'; + +export const isOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isOperatorLabel', { + defaultMessage: 'is', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is', +}; + +export const isNotOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotOperatorLabel', { + defaultMessage: 'is not', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is_not', +}; + +export const isOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isOneOfOperatorLabel', { + defaultMessage: 'is one of', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: 'is_one_of', +}; + +export const isNotOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotOneOfOperatorLabel', { + defaultMessage: 'is not one of', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: 'is_not_one_of', +}; + +export const existsOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.existsOperatorLabel', { + defaultMessage: 'exists', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.EXISTS, + value: 'exists', +}; + +export const doesNotExistOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.doesNotExistOperatorLabel', { + defaultMessage: 'does not exist', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.EXISTS, + value: 'does_not_exist', +}; + +export const isInListOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isInListOperatorLabel', { + defaultMessage: 'is in list', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.LIST, + value: 'is_in_list', +}; + +export const isNotInListOperator: OperatorOption = { + message: i18n.translate('xpack.lists.exceptions.isNotInListOperatorLabel', { + defaultMessage: 'is not in list', + }), + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.LIST, + value: 'is_not_in_list', +}; + +export const EXCEPTION_OPERATORS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, + isInListOperator, + isNotInListOperator, +]; + +export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, +]; + +export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [ + isInListOperator, + isNotInListOperator, +]; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts new file mode 100644 index 0000000000000..065239246d329 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOADING = i18n.translate('xpack.lists.autocomplete.loadingDescription', { + defaultMessage: 'Loading...', +}); + +export const SELECT_FIELD_FIRST = i18n.translate('xpack.lists.autocomplete.selectField', { + defaultMessage: 'Please select a field first...', +}); + +export const FIELD_REQUIRED_ERR = i18n.translate('xpack.lists.autocomplete.fieldRequiredError', { + defaultMessage: 'Value cannot be empty', +}); + +export const NUMBER_ERR = i18n.translate('xpack.lists.autocomplete.invalidNumberError', { + defaultMessage: 'Not a valid number', +}); + +export const DATE_ERR = i18n.translate('xpack.lists.autocomplete.invalidDateError', { + defaultMessage: 'Not a valid date', +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts new file mode 100644 index 0000000000000..8ea3e8d927d68 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; + +export interface GetGenericComboBoxPropsReturn { + comboOptions: EuiComboBoxOptionOption[]; + labels: string[]; + selectedComboOptions: EuiComboBoxOptionOption[]; +} + +export interface OperatorOption { + message: string; + value: string; + operator: OperatorEnum; + type: OperatorTypeEnum; +} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx index 16678e4da2a1d..dc773e222776b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.test.tsx @@ -9,8 +9,9 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + import { BuilderAndBadgeComponent } from './and_badge'; -import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx similarity index 96% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx index fd561110d885f..6f867d772072f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/and_badge.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/and_badge.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { AndOrBadge } from '../../and_or_badge'; +import { AndOrBadge } from '../and_or_badge'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx index d86e668a93ad6..9ed8b2c41b4ba 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.test.tsx @@ -8,8 +8,8 @@ import { mount } from 'enzyme'; import React from 'react'; -import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; +import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx index 48bdeb4d5b044..01739bd3f85cb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_delete_button.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_delete_button.tsx @@ -9,7 +9,7 @@ import React, { useCallback } from 'react'; import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; -import { BuilderEntry } from '../types'; +import { BuilderEntry } from './types'; const MyFirstRowContainer = styled(EuiFlexItem)` padding-top: 20px; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx new file mode 100644 index 0000000000000..8408fb7a6a4f1 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.stories.tsx @@ -0,0 +1,201 @@ +/* + * 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 { Story, addDecorator } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { HttpStart } from 'kibana/public'; + +import { OperatorEnum, OperatorTypeEnum } from '../../../../common'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; + +import { BuilderEntryItem, EntryItemProps } from './entry_renderer'; + +const mockTheme = getMockTheme({ + darkMode: false, + eui: euiLightVars, +}); +const mockAutocompleteService = ({ + getValueSuggestions: () => + new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + field: { + aggregatable: true, + count: 30, + esTypes: ['date'], + name: '@timestamp', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'date', + }, + type: 'field', + }, + { + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + type: 'field', + }, + ]); + }, 300); + }), +} as unknown) as AutocompleteStart; + +addDecorator((storyFn) => {storyFn()}); + +export default { + argTypes: { + allowLargeValueLists: { + control: { + type: 'boolean', + }, + description: '`boolean` - set to true to allow large value lists.', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + autoCompleteService: { + control: { + type: 'object', + }, + description: + '`AutocompleteStart` - Kibana data plugin autocomplete service used for field value autocomplete.', + type: { + required: true, + }, + }, + entry: { + control: { + type: 'object', + }, + description: '`FormattedBuilderEntry` - A single exception item entry.', + type: { + required: true, + }, + }, + httpService: { + control: { + type: 'object', + }, + description: '`HttpStart` - Kibana service.', + type: { + required: true, + }, + }, + indexPattern: { + description: + '`IIndexPattern` - index patterns used to populate field options and value autocomplete.', + type: { + required: true, + }, + }, + listType: { + control: { + options: ['detection', 'endpoint'], + type: 'select', + }, + description: + '`ExceptionListType` - Depending on the list type, certain validations may apply.', + type: { + required: true, + }, + }, + + onChange: { + description: + '`(arg: BuilderEntry, i: number) => void` - callback invoked any time field, operator or value is updated.', + type: { + required: true, + }, + }, + onlyShowListOperators: { + description: + '`boolean` - set to true to display to user only operators related to large value lists. This is currently used due to limitations around combining large value list exceptions and non-large value list exceptions.', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + setErrorsExist: { + description: '`(arg: boolean) => void` - callback invoked to bubble up input errors.', + type: { + required: true, + }, + }, + showLabel: { + description: + '`boolean` - whether or not to show the input labels (normally just wanted for the first entry item).', + table: { + defaultValue: { + summary: false, + }, + }, + type: { + required: false, + }, + }, + }, + component: BuilderEntryItem, + title: 'BuilderEntryItem', +}; + +const BuilderEntryItemTemplate: Story = (args) => ; + +export const Default = BuilderEntryItemTemplate.bind({}); +Default.args = { + autocompleteService: mockAutocompleteService, + + entry: { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: 'e37ad550-05d2-470e-9a95-487db201ab56', + nested: undefined, + operator: { + message: 'is', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'is', + }, + parent: undefined, + value: '', + }, + httpService: {} as HttpStart, + indexPattern: { + fields, + id: '1234', + title: 'logstash-*', + }, + listType: 'detection', + onChange: action('onClick'), + onlyShowListOperators: false, + setErrorsExist: action('onClick'), + showLabel: false, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 9c9035d7e66e9..8f6f9329f2974 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -8,47 +8,46 @@ import { ReactWrapper, mount } from 'enzyme'; import React from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; -import { BuilderEntryItem } from './entry_item'; import { - isOperator, - isNotOperator, - isOneOfOperator, - isNotOneOfOperator, + doesNotExistOperator, + existsOperator, isInListOperator, isNotInListOperator, - existsOperator, - doesNotExistOperator, -} from '../../autocomplete/operators'; + isNotOneOfOperator, + isNotOperator, + isOneOfOperator, + isOperator, +} from '../autocomplete/operators'; import { fields, getField, -} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getFoundListSchemaMock } from '../../../../../../lists/common/schemas/response/found_list_schema.mock'; -import { getEmptyValue } from '../../empty_value'; -import { waitFor } from '@testing-library/dom'; +} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getFoundListSchemaMock } from '../../../../common/schemas/response/found_list_schema.mock'; +import { useFindLists } from '../../../lists/hooks/use_find_lists'; -// mock out lists hook -const mockStart = jest.fn(); -const mockResult = getFoundListSchemaMock(); -jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../lists_plugin_deps', () => { - const originalModule = jest.requireActual('../../../../lists_plugin_deps'); +import { BuilderEntryItem } from './entry_renderer'; - return { - ...originalModule, - useFindLists: () => ({ - loading: false, - start: mockStart.mockReturnValue(mockResult), - result: mockResult, - error: undefined, - }), - }; -}); +jest.mock('../../../lists/hooks/use_find_lists'); + +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('BuilderEntryItem', () => { let wrapper: ReactWrapper; + beforeEach(() => { + (useFindLists as jest.Mock).mockReturnValue({ + error: undefined, + loading: false, + result: getFoundListSchemaMock(), + start: jest.fn(), + }); + }); + afterEach(() => { jest.clearAllMocks(); wrapper.unmount(); @@ -57,25 +56,27 @@ describe('BuilderEntryItem', () => { test('it renders field labels if "showLabel" is "true"', () => { wrapper = mount( ); @@ -85,25 +86,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isOperator"', () => { wrapper = mount( ); @@ -117,25 +120,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotOperator"', () => { wrapper = mount( ); @@ -151,25 +156,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isOneOfOperator"', () => { wrapper = mount( ); @@ -185,25 +192,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotOneOfOperator"', () => { wrapper = mount( ); @@ -219,25 +228,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isInListOperator"', () => { wrapper = mount( ); @@ -253,25 +264,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "isNotInListOperator"', () => { wrapper = mount( ); @@ -287,25 +300,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "existsOperator"', () => { wrapper = mount( ); @@ -313,9 +328,7 @@ describe('BuilderEntryItem', () => { expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual( 'exists' ); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual( - getEmptyValue() - ); + expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—'); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled ).toBeTruthy(); @@ -324,25 +337,27 @@ describe('BuilderEntryItem', () => { test('it renders field values correctly when operator is "doesNotExistOperator"', () => { wrapper = mount( ); @@ -350,9 +365,7 @@ describe('BuilderEntryItem', () => { expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual( 'does not exist' ); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual( - getEmptyValue() - ); + expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—'); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled ).toBeTruthy(); @@ -361,57 +374,59 @@ describe('BuilderEntryItem', () => { test('it uses "correspondingKeywordField" if it exists', () => { wrapper = mount( ); expect( wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField') ).toEqual({ - name: 'extension', - type: 'string', - esTypes: ['keyword'], + aggregatable: true, count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, scripted: false, searchable: true, - aggregatable: true, - readFromDocValues: true, + type: 'string', }); }); @@ -419,25 +434,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -446,7 +463,7 @@ describe('BuilderEntryItem', () => { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' }, + { field: 'machine.os', id: '123', operator: 'included', type: 'match', value: '' }, 0 ); }); @@ -455,25 +472,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -482,7 +501,7 @@ describe('BuilderEntryItem', () => { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, + { field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -491,25 +510,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -518,7 +539,7 @@ describe('BuilderEntryItem', () => { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' }, + { field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '126.45.211.34' }, 0 ); }); @@ -527,25 +548,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -554,7 +577,7 @@ describe('BuilderEntryItem', () => { }).onCreateOption('126.45.211.34'); expect(mockOnChange).toHaveBeenCalledWith( - { id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, + { field: 'ip', id: '123', operator: 'included', type: 'match_any', value: ['126.45.211.34'] }, 0 ); }); @@ -563,25 +586,27 @@ describe('BuilderEntryItem', () => { const mockOnChange = jest.fn(); wrapper = mount( ); @@ -591,11 +616,11 @@ describe('BuilderEntryItem', () => { expect(mockOnChange).toHaveBeenCalledWith( { - id: '123', field: 'ip', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, operator: 'excluded', type: 'list', - list: { id: 'some-list-id', type: 'ip' }, }, 0 ); @@ -605,25 +630,27 @@ describe('BuilderEntryItem', () => { const mockSetErrorExists = jest.fn(); wrapper = mount( ); @@ -640,25 +667,27 @@ describe('BuilderEntryItem', () => { const mockSetErrorExists = jest.fn(); wrapper = mount( ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index af3b5362cbbf2..7c45f1c35c55e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -6,58 +6,65 @@ */ import React, { useCallback } from 'react'; -import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import styled from 'styled-components'; -import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils'; -import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { FieldComponent } from '../../autocomplete/field'; -import { OperatorComponent } from '../../autocomplete/operator'; -import { OperatorOption } from '../../autocomplete/types'; -import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match'; -import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any'; -import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists'; -import { FormattedBuilderEntry, BuilderEntry } from '../types'; -import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists'; -import { ListSchema, OperatorTypeEnum, ExceptionListType } from '../../../../lists_plugin_deps'; -import { getEmptyValue } from '../../empty_value'; -import * as i18n from './translations'; +import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { HttpStart } from '../../../../../../../src/core/public'; +import { FieldComponent } from '../autocomplete/field'; +import { OperatorComponent } from '../autocomplete/operator'; +import { OperatorOption } from '../autocomplete/types'; +import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../autocomplete/operators'; +import { AutocompleteFieldExistsComponent } from '../autocomplete/field_value_exists'; +import { AutocompleteFieldMatchComponent } from '../autocomplete/field_value_match'; +import { AutocompleteFieldMatchAnyComponent } from '../autocomplete/field_value_match_any'; +import { AutocompleteFieldListsComponent } from '../autocomplete/field_value_lists'; +import { ExceptionListType, ListSchema, OperatorTypeEnum } from '../../../../common'; +import { getEmptyValue } from '../../../common/empty_value'; + import { - getFilteredIndexPatterns, - getOperatorOptions, getEntryOnFieldChange, - getEntryOnOperatorChange, - getEntryOnMatchChange, - getEntryOnMatchAnyChange, getEntryOnListChange, + getEntryOnMatchAnyChange, + getEntryOnMatchChange, + getEntryOnOperatorChange, + getFilteredIndexPatterns, + getOperatorOptions, } from './helpers'; -import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../../autocomplete/operators'; +import { BuilderEntry, FormattedBuilderEntry } from './types'; +import * as i18n from './translations'; const MyValuesInput = styled(EuiFlexItem)` overflow: hidden; `; -interface EntryItemProps { +export interface EntryItemProps { + allowLargeValueLists?: boolean; + autocompleteService: AutocompleteStart; entry: FormattedBuilderEntry; + httpService: HttpStart; indexPattern: IIndexPattern; - showLabel: boolean; listType: ExceptionListType; + listTypeSpecificFilter?: (pattern: IIndexPattern, type: ExceptionListType) => IIndexPattern; onChange: (arg: BuilderEntry, i: number) => void; - setErrorsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; - ruleType?: Type; + setErrorsExist: (arg: boolean) => void; + showLabel: boolean; } export const BuilderEntryItem: React.FC = ({ + allowLargeValueLists = false, + autocompleteService, entry, + httpService, indexPattern, listType, - showLabel, + listTypeSpecificFilter, onChange, - setErrorsExist, onlyShowListOperators = false, - ruleType, + setErrorsExist, + showLabel, }): JSX.Element => { const handleError = useCallback( (err: boolean): void => { @@ -112,7 +119,12 @@ export const BuilderEntryItem: React.FC = ({ const renderFieldInput = useCallback( (isFirst: boolean): JSX.Element => { - const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry, listType); + const filteredIndexPatterns = getFilteredIndexPatterns( + indexPattern, + entry, + listType, + listTypeSpecificFilter + ); const comboBox = ( = ({ ); } }, - [handleFieldChange, indexPattern, entry, listType] + [indexPattern, entry, listType, listTypeSpecificFilter, handleFieldChange] ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { @@ -155,7 +167,7 @@ export const BuilderEntryItem: React.FC = ({ entry, listType, entry.field != null && entry.field.type === 'boolean', - isFirst && !isEqlRule(ruleType) && !isThresholdRule(ruleType) + isFirst && !allowLargeValueLists ); const comboBox = ( = ({ const value = typeof entry.value === 'string' ? entry.value : undefined; return ( = ({ const values: string[] = Array.isArray(entry.value) ? entry.value : []; return ( = ({ const id = typeof entry.value === 'string' ? entry.value : undefined; return ( = ({ } isClearable={false} onChange={handleFieldListValueChange} - isRequired data-test-subj="exceptionBuilderEntryFieldList" /> ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx similarity index 82% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index cbeb987f49b7b..0fd886bdc742a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -8,39 +8,28 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; -import { useKibana } from '../../../../common/lib/kibana'; -import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; -import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock'; +import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { getMockTheme } from '../../../common/test_utils/kibana_react.mock'; -import { BuilderExceptionListItemComponent } from './exception_item'; -import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; +import { BuilderExceptionListItemComponent } from './exception_item_renderer'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece', }, }); - -jest.mock('../../../../common/lib/kibana'); +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('BuilderExceptionListItemComponent', () => { const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - beforeAll(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - afterEach(() => { getValueSuggestionsMock.mockClear(); }); @@ -54,20 +43,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -83,20 +74,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -110,20 +103,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -139,20 +134,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -175,20 +172,22 @@ describe('BuilderExceptionListItemComponent', () => { }; const wrapper = mount( ); @@ -203,20 +202,22 @@ describe('BuilderExceptionListItemComponent', () => { const wrapper = mount( ); @@ -230,22 +231,24 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ); @@ -259,20 +262,22 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( ); @@ -288,20 +293,22 @@ describe('BuilderExceptionListItemComponent', () => { exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()]; const wrapper = mount( ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx similarity index 85% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx rename to x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index f9afa48408e39..d151ec5a81ec3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/exception_item.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -5,22 +5,24 @@ * 2.0. */ -import React, { useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; +import { HttpStart } from 'kibana/public'; +import { AutocompleteStart } from 'src/plugins/data/public'; -import { Type } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; -import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; -import { ExceptionListType } from '../../../../../public/lists_plugin_deps'; -import { BuilderEntryItem } from './entry_item'; -import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; +import { ExceptionListType } from '../../../../common'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; + +import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from './types'; import { BuilderAndBadgeComponent } from './and_badge'; +import { BuilderEntryDeleteButtonComponent } from './entry_delete_button'; +import { BuilderEntryItem } from './entry_renderer'; +import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; const MyBeautifulLine = styled(EuiFlexItem)` &:after { - background: ${({ theme }) => theme.eui.euiColorLightShade}; + background: ${({ theme }): string => theme.eui.euiColorLightShade}; content: ''; width: 2px; height: 40px; @@ -34,8 +36,10 @@ const MyOverflowContainer = styled(EuiFlexItem)` `; interface BuilderExceptionListItemProps { + allowLargeValueLists: boolean; + httpService: HttpStart; + autocompleteService: AutocompleteStart; exceptionItem: ExceptionsBuilderExceptionItem; - exceptionId: string; exceptionItemIndex: number; indexPattern: IIndexPattern; andLogicIncluded: boolean; @@ -45,13 +49,14 @@ interface BuilderExceptionListItemProps { onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; setErrorsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; - ruleType?: Type; } export const BuilderExceptionListItemComponent = React.memo( ({ + allowLargeValueLists, + httpService, + autocompleteService, exceptionItem, - exceptionId, exceptionItemIndex, indexPattern, isOnlyItem, @@ -61,7 +66,6 @@ export const BuilderExceptionListItemComponent = React.memo { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -119,6 +123,9 @@ export const BuilderExceptionListItemComponent = React.memo} ({ + v4: jest.fn().mockReturnValue('123'), +})); + +const getEntryExistsWithIdMock = (): EntryExists & { id: string } => ({ + ...getEntryExistsMock(), + id: '123', +}); + +const getEntryNestedWithIdMock = (): EntryNested & { id: string } => ({ + ...getEntryNestedMock(), + id: '123', +}); + +const getEntryMatchWithIdMock = (): EntryMatch & { id: string } => ({ + ...getEntryMatchMock(), + id: '123', +}); + +const getEntryMatchAnyWithIdMock = (): EntryMatchAny & { id: string } => ({ + ...getEntryMatchAnyMock(), + id: '123', +}); + +const getMockIndexPattern = (): IIndexPattern => ({ + fields, + id: '1234', + title: 'logstash-*', +}); + +const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: getField('ip'), + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some value', +}); + +const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: getField('nestedField.child'), + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + value: 'some value', +}); + +const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + correspondingKeywordField: undefined, + entryIndex: 0, + field: { ...getField('nestedField.child'), esTypes: ['nested'], name: 'nestedField' }, + id: '123', + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, +}); + +const mockEndpointFields = [ + { + aggregatable: false, + count: 0, + esTypes: ['keyword'], + name: 'file.path.caseless', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.Ext.code_signature.status', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'file.Ext.code_signature' } }, + type: 'string', + }, +]; + +export const getEndpointField = (name: string): IFieldType => + mockEndpointFields.find((field) => field.name === name) as IFieldType; + +const filterIndexPatterns = (patterns: IIndexPattern, type: ExceptionListType): IIndexPattern => { + return type === 'endpoint' + ? { + ...patterns, + fields: patterns.fields.filter(({ name }) => + ['file.path.caseless', 'file.Ext.code_signature.status'].includes(name) + ), + } + : patterns; +}; + +describe('Exception builder helpers', () => { + describe('#getFilteredIndexPatterns', () => { + describe('list type detections', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'child' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), esTypes: ['nested'], name: 'nestedField' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + + describe('list type endpoint', () => { + let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + + beforeAll(() => { + payloadIndexPattern = { + ...payloadIndexPattern, + fields: [...payloadIndexPattern.fields, ...mockEndpointFields], + }; + }); + + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: getEndpointField('file.Ext.code_signature.status'), + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'file.Ext.code_signature', + }, + parentIndex: 0, + }, + value: 'some value', + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: 'status' }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: { + ...getEndpointField('file.Ext.code_signature.status'), + esTypes: ['nested'], + name: 'file.Ext.code_signature', + }, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['nested'], + name: 'file.Ext.code_signature', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'file.Ext.code_signature', + }, + }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns( + payloadIndexPattern, + payloadItem, + 'endpoint', + filterIndexPatterns + ); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns( + payloadIndexPattern, + payloadItem, + 'endpoint', + filterIndexPatterns + ); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['keyword'], + name: 'file.path.caseless', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.Ext.code_signature.status', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'file.Ext.code_signature' } }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + }); + + describe('#getEntryFromOperator', () => { + test('it returns current value when switching from "is" to "is not"', () => { + const payloadOperator: OperatorOption = isNotOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH, + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not" to "is"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatch & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is one of" to "is not one of"', () => { + const payloadOperator: OperatorOption = isNotOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH_ANY, + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not one of" to "is one of"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match_any"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryMatchAny & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "exists" to "does not exist"', () => { + const payloadOperator: OperatorOption = doesNotExistOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: existsOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: 'excluded', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "does not exist" to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: doesNotExistOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryExists & { id?: string } = { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "list"', () => { + const payloadOperator: OperatorOption = isInListOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: EntryList & { id?: string } = { + field: 'ip', + id: '123', + list: { id: '', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getOperatorOptions', () => { + test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if no field selected', () => { + const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', true); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns all operator options if "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = EXCEPTION_OPERATORS; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [ + isOperator, + isNotOperator, + existsOperator, + doesNotExistOperator, + ]; + expect(output).toEqual(expected); + }); + + test('it returns list operators if specified to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, true); + expect(output).toEqual(EXCEPTION_OPERATORS); + }); + + test('it does not return list operators if specified not to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, false); + expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); + }); + }); + + describe('#getEntryOnFieldChange', () => { + test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [ + { ...getEntryMatchWithIdMock(), field: 'child' }, + getEntryMatchAnyWithIdMock(), + ], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + getEntryMatchAnyWithIdMock(), + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns field of type "match" with updated field if not a nested entry', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadIFieldType: IFieldType = getField('ip'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: '', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnOperatorChange', () => { + test('it returns updated subentry preserving its value when entry is not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: 'excluded', + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.EXCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: [], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'jibber jabber', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchAnyChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + field: undefined, + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + field: undefined, + parent: { + parent: { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], + field: 'nestedField', + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH_ANY, + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnListChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + field: undefined, + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { + index: 0, + updatedEntry: { + field: '', + id: '123', + list: { id: 'some-list-id', type: 'ip' }, + operator: OperatorEnum.INCLUDED, + type: 'list', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntries', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: undefined, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when no nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, + ]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + correspondingKeywordField: undefined, + entryIndex: 1, + field: { + aggregatable: true, + count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'string', + }, + id: '123', + nested: undefined, + operator: isOneOfOperator, + parent: undefined, + value: ['some extension'], + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadParent: EntryNested = { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }; + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, + { ...payloadParent }, + ]; + + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + correspondingKeywordField: undefined, + entryIndex: 1, + field: { + aggregatable: false, + esTypes: ['nested'], + name: 'nestedField', + searchable: false, + type: 'string', + }, + id: '123', + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }, + { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [ + { + field: 'child', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some host name', + }, + ], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + parentIndex: 1, + }, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + }); + + describe('#getUpdatedEntriesOnDelete', () => { + test('it removes entry corresponding to "entryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: ENTRIES_WITH_IDS, + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + id: '123', + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.MATCH, + value: 'some value', + }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes nested entry of "entryIndex" with corresponding parent index', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryExistsWithIdMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [], + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntry', () => { + test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { + const payloadIndexPattern: IIndexPattern = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'machine.os.raw.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: getField('machine.os.raw'), + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'machine.os.raw.text', + readFromDocValues: true, + scripted: false, + searchable: false, + type: 'string', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + }; + expect(output).toEqual(expected); + }); + + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; + const payloadParent: EntryNested = { + ...getEntryNestedWithIdMock(), + entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], + field: 'nestedField', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + payloadParent, + 1 + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + id: '123', + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [{ ...payloadItem }], + field: 'nestedField', + id: '123', + type: OperatorTypeEnum.NESTED, + }, + parentIndex: 1, + }, + value: 'some host name', + }; + expect(output).toEqual(expected); + }); + + test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { + ...getEntryMatchWithIdMock(), + field: 'ip', + value: 'some ip', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + id: '123', + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#isEntryNested', () => { + test('it returns "false" if payload is not of type EntryNested', () => { + const payload: BuilderEntry = getEntryMatchWithIdMock(); + const output = isEntryNested(payload); + const expected = false; + expect(output).toEqual(expected); + }); + + test('it returns "true if payload is of type EntryNested', () => { + const payload: EntryNested = getEntryNestedWithIdMock(); + const output = isEntryNested(payload); + const expected = true; + expect(output).toEqual(expected); + }); + }); + + describe('#getCorrespondingKeywordField', () => { + test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw.text', + }); + + expect(output).toEqual(getField('machine.os.raw')); + }); + + test('it returns undefined if "selectedFieldIsTextType" is false', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is empty string', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: '', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is undefined', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: undefined, + }); + + expect(output).toEqual(undefined); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts new file mode 100644 index 0000000000000..b3ed9d296a218 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/helpers.ts @@ -0,0 +1,667 @@ +/* + * 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 { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public'; +import { addIdToItem } from '../../../../common/shared_imports'; +import { + Entry, + EntryNested, + ExceptionListType, + ListSchema, + OperatorTypeEnum, + entriesList, +} from '../../../../common'; +import { + EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOneOfOperator, + isOperator, +} from '../autocomplete/operators'; +import { OperatorOption } from '../autocomplete/types'; + +import { + BuilderEntry, + EmptyNestedEntry, + ExceptionsBuilderExceptionItem, + FormattedBuilderEntry, +} from './types'; + +export const isEntryNested = (item: BuilderEntry): item is EntryNested => { + return (item as EntryNested).entries != null; +}; + +/** + * Returns the operator type, may not need this if using io-ts types + * + * @param item a single ExceptionItem entry + */ +export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { + switch (item.type) { + case 'match': + return OperatorTypeEnum.MATCH; + case 'match_any': + return OperatorTypeEnum.MATCH_ANY; + case 'list': + return OperatorTypeEnum.LIST; + default: + return OperatorTypeEnum.EXISTS; + } +}; + +/** + * Determines operator selection (is/is not/is one of, etc.) + * Default operator is "is" + * + * @param item a single ExceptionItem entry + */ +export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => { + if (item.type === 'nested') { + return isOperator; + } else { + const operatorType = getOperatorType(item); + const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { + return item.operator === operatorOption.operator && operatorType === operatorOption.type; + }); + + return foundOperator ?? isOperator; + } +}; + +/** + * Returns the fields corresponding value for an entry + * + * @param item a single ExceptionItem entry + */ +export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => { + switch (item.type) { + case OperatorTypeEnum.MATCH: + case OperatorTypeEnum.MATCH_ANY: + return item.value; + case OperatorTypeEnum.EXISTS: + return undefined; + case OperatorTypeEnum.LIST: + return item.list.id; + default: + return undefined; + } +}; + +/** + * Determines whether an entire entry, exception item, or entry within a nested + * entry needs to be removed + * + * @param exceptionItem + * @param entryIndex index of given entry, for nested entries, this will correspond + * to their parent index + * @param nestedEntryIndex index of nested entry + * + */ +export const getUpdatedEntriesOnDelete = ( + exceptionItem: ExceptionsBuilderExceptionItem, + entryIndex: number, + nestedParentIndex: number | null +): ExceptionsBuilderExceptionItem => { + const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; + + if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { + const updatedEntryEntries = [ + ...itemOfInterest.entries.slice(0, entryIndex), + ...itemOfInterest.entries.slice(entryIndex + 1), + ]; + + if (updatedEntryEntries.length === 0) { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, nestedParentIndex), + ...exceptionItem.entries.slice(nestedParentIndex + 1), + ], + }; + } else { + const { field } = itemOfInterest; + const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { + entries: updatedEntryEntries, + field, + id: itemOfInterest.id ?? `${entryIndex}`, + type: OperatorTypeEnum.NESTED, + }; + + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, nestedParentIndex), + updatedItemOfInterest, + ...exceptionItem.entries.slice(nestedParentIndex + 1), + ], + }; + } + } else { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } +}; + +/** + * Returns filtered index patterns based on the field - if a user selects to + * add nested entry, should only show nested fields, if item is the parent + * field of a nested entry, we only display the parent field + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * set to add a nested field + */ +export const getFilteredIndexPatterns = ( + patterns: IIndexPattern, + item: FormattedBuilderEntry, + type: ExceptionListType, + preFilter?: (i: IIndexPattern, t: ExceptionListType) => IIndexPattern +): IIndexPattern => { + const indexPatterns = preFilter != null ? preFilter(patterns, type) : patterns; + + if (item.nested === 'child' && item.parent != null) { + // when user has selected a nested entry, only fields with the common parent are shown + return { + ...indexPatterns, + fields: indexPatterns.fields + .filter((indexField) => { + const fieldHasCommonParentPath = + indexField.subType != null && + indexField.subType.nested != null && + item.parent != null && + indexField.subType.nested.path === item.parent.parent.field; + + return fieldHasCommonParentPath; + }) + .map((f) => { + const [fieldNameWithoutParentPath] = f.name.split('.').slice(-1); + return { ...f, name: fieldNameWithoutParentPath }; + }), + }; + } else if (item.nested === 'parent' && item.field != null) { + // when user has selected a nested entry, right above it we show the common parent + return { ...indexPatterns, fields: [item.field] }; + } else if (item.nested === 'parent' && item.field == null) { + // when user selects to add a nested entry, only nested fields are shown as options + return { + ...indexPatterns, + fields: indexPatterns.fields.filter( + (field) => field.subType != null && field.subType.nested != null + ), + }; + } else { + return indexPatterns; + } +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current exception item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnFieldChange = ( + item: FormattedBuilderEntry, + newField: IFieldType +): { index: number; updatedEntry: BuilderEntry } => { + const { parent, entryIndex, nested } = item; + const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; + + if (nested === 'parent') { + // For nested entries, when user first selects to add a nested + // entry, they first see a row similar to what is shown for when + // a user selects "exists", as soon as they make a selection + // we can now identify the 'parent' and 'child' this is where + // we first convert the entry into type "nested" + const newParentFieldValue = + newField.subType != null && newField.subType.nested != null + ? newField.subType.nested.path + : ''; + + return { + index: entryIndex, + updatedEntry: { + entries: [ + addIdToItem({ + field: newChildFieldValue ?? '', + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }), + ], + field: newParentFieldValue, + id: item.id, + type: OperatorTypeEnum.NESTED, + }, + }; + } else if (nested === 'child' && parent != null) { + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: newChildFieldValue ?? '', + id: item.id, + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: newField != null ? newField.name : '', + id: item.id, + operator: isOperator.operator, + type: OperatorTypeEnum.MATCH, + value: '', + }, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "list" + * + * @param item - current exception item entry values + * @param newField - newly selected list + * + */ +export const getEntryOnListChange = ( + item: FormattedBuilderEntry, + newField: ListSchema +): { index: number; updatedEntry: BuilderEntry } => { + const { entryIndex, field, operator } = item; + const { id, type } = newField; + + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + list: { id, type }, + operator: operator.operator, + type: OperatorTypeEnum.LIST, + }, + }; +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match_any" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchAnyChange = ( + item: FormattedBuilderEntry, + newField: string[] +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: newField, + }, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchChange = ( + item: FormattedBuilderEntry, + newField: string +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.MATCH, + value: newField, + }, + }; + } +}; + +/** + * On operator change, determines whether value needs to be cleared or not + * + * @param field + * @param selectedOperator + * @param currentEntry + * + */ +export const getEntryFromOperator = ( + selectedOperator: OperatorOption, + currentEntry: FormattedBuilderEntry +): Entry & { id?: string } => { + const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; + const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; + switch (selectedOperator.type) { + case 'match': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.MATCH, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; + case 'match_any': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.MATCH_ANY, + value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], + }; + case 'list': + return { + field: fieldValue, + id: currentEntry.id, + list: { id: '', type: 'ip' }, + operator: selectedOperator.operator, + type: OperatorTypeEnum.LIST, + }; + default: + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.EXISTS, + }; + } +}; + +/** + * Determines proper entry update when user selects new operator + * + * @param item - current exception item entry values + * @param newOperator - newly selected operator + * + */ +export const getEntryOnOperatorChange = ( + item: FormattedBuilderEntry, + newOperator: OperatorOption +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, field, nested } = item; + const newEntry = getEntryFromOperator(newOperator, item); + + if (!entriesList.is(newEntry) && nested != null && parent != null) { + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + ...newEntry, + field: field != null ? field.name.split('.').slice(-1)[0] : '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { index: entryIndex, updatedEntry: newEntry }; + } +}; + +/** + * Determines which operators to make available + * + * @param item + * @param listType + * @param isBoolean + * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators + */ +export const getOperatorOptions = ( + item: FormattedBuilderEntry, + listType: ExceptionListType, + isBoolean: boolean, + includeValueListOperators = true +): OperatorOption[] => { + if (item.nested === 'parent' || item.field == null) { + return [isOperator]; + } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { + return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; + } else if (item.nested != null && listType === 'detection') { + return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; + } else { + return isBoolean + ? [isOperator, isNotOperator, existsOperator, doesNotExistOperator] + : includeValueListOperators + ? EXCEPTION_OPERATORS + : EXCEPTION_OPERATORS_SANS_LISTS; + } +}; + +/** + * Fields of type 'text' do not generate autocomplete values, we want + * to find it's corresponding keyword type (if available) which does + * generate autocomplete values + * + * @param fields IFieldType fields + * @param selectedField the field name that was selected + * @param isTextType we only want a corresponding keyword field if + * the selected field is of type 'text' + * + */ +export const getCorrespondingKeywordField = ({ + fields, + selectedField, +}: { + fields: IFieldType[]; + selectedField: string | undefined; +}): IFieldType | undefined => { + const selectedFieldBits = + selectedField != null && selectedField !== '' ? selectedField.split('.') : []; + const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; + + if (selectedFieldIsTextType && selectedFieldBits.length > 0) { + const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); + const [foundKeywordField] = fields.filter( + ({ name }) => keywordField !== '' && keywordField === name + ); + return foundKeywordField; + } + + return undefined; +}; + +/** + * Formats the entry into one that is easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param itemIndex entry index + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntry = ( + indexPattern: IIndexPattern, + item: BuilderEntry, + itemIndex: number, + parent: EntryNested | undefined, + parentIndex: number | undefined +): FormattedBuilderEntry => { + const { fields } = indexPattern; + const field = parent != null ? `${parent.field}.${item.field}` : item.field; + const [foundField] = fields.filter(({ name }) => field != null && field === name); + const correspondingKeywordField = getCorrespondingKeywordField({ + fields, + selectedField: field, + }); + + if (parent != null && parentIndex != null) { + return { + correspondingKeywordField, + entryIndex: itemIndex, + field: + foundField != null + ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } + : foundField, + id: item.id ?? `${itemIndex}`, + nested: 'child', + operator: getExceptionOperatorSelect(item), + parent: { parent, parentIndex }, + value: getEntryValue(item), + }; + } else { + return { + correspondingKeywordField, + entryIndex: itemIndex, + field: foundField, + id: item.id ?? `${itemIndex}`, + nested: undefined, + operator: getExceptionOperatorSelect(item), + parent: undefined, + value: getEntryValue(item), + }; + } +}; + +/** + * Formats the entries to be easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param entries exception item entries + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntries = ( + indexPattern: IIndexPattern, + entries: BuilderEntry[], + parent?: EntryNested, + parentIndex?: number +): FormattedBuilderEntry[] => { + return entries.reduce((acc, item, index) => { + const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; + if (item.type !== 'nested' && !isNewNestedEntry) { + const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( + indexPattern, + item, + index, + parent, + parentIndex + ); + return [...acc, newItemEntry]; + } else { + const parentEntry: FormattedBuilderEntry = { + correspondingKeywordField: undefined, + entryIndex: index, + field: isNewNestedEntry + ? undefined + : { + aggregatable: false, + esTypes: ['nested'], + name: item.field ?? '', + searchable: false, + type: 'string', + }, + id: item.id ?? `${index}`, + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }; + + // User has selected to add a nested field, but not yet selected the field + if (isNewNestedEntry) { + return [...acc, parentEntry]; + } + + if (isEntryNested(item)) { + const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + + return [...acc, parentEntry, ...nestedItems]; + } + + return [...acc]; + } + }, []); +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts new file mode 100644 index 0000000000000..9da598c08bd83 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FIELD = i18n.translate('xpack.lists.exceptions.builder.fieldLabel', { + defaultMessage: 'Field', +}); + +export const OPERATOR = i18n.translate('xpack.lists.exceptions.builder.operatorLabel', { + defaultMessage: 'Operator', +}); + +export const VALUE = i18n.translate('xpack.lists.exceptions.builder.valueLabel', { + defaultMessage: 'Value', +}); + +export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldValuePlaceholder', + { + defaultMessage: 'Search field value...', + } +); + +export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldNestedPlaceholder', + { + defaultMessage: 'Search nested field', + } +); + +export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionListsPlaceholder', + { + defaultMessage: 'Search for list...', + } +); + +export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionFieldPlaceholder', + { + defaultMessage: 'Search', + } +); + +export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( + 'xpack.lists.exceptions.builder.exceptionOperatorPlaceholder', + { + defaultMessage: 'Operator', + } +); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/types.ts b/x-pack/plugins/lists/public/exceptions/components/builder/types.ts new file mode 100644 index 0000000000000..cdb4f735aa103 --- /dev/null +++ b/x-pack/plugins/lists/public/exceptions/components/builder/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { OperatorOption } from '../autocomplete/types'; +import { + CreateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + ExceptionListItemSchema, + OperatorEnum, + OperatorTypeEnum, +} from '../../../../common'; + +export interface FormattedBuilderEntry { + id: string; + field: IFieldType | undefined; + operator: OperatorOption; + value: string | string[] | undefined; + nested: 'parent' | 'child' | undefined; + entryIndex: number; + parent: { parent: BuilderEntryNested; parentIndex: number } | undefined; + correspondingKeywordField: IFieldType | undefined; +} + +export interface EmptyEntry { + id: string; + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY; + value: string | string[] | undefined; +} + +export interface EmptyListEntry { + id: string; + field: string | undefined; + operator: OperatorEnum; + type: OperatorTypeEnum.LIST; + list: { id: string | undefined; type: string | undefined }; +} + +export interface EmptyNestedEntry { + id: string; + field: string | undefined; + type: OperatorTypeEnum.NESTED; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +} + +export type BuilderEntry = + | (Entry & { id?: string }) + | EmptyListEntry + | EmptyEntry + | BuilderEntryNested + | EmptyNestedEntry; + +export type BuilderEntryNested = Omit & { + id?: string; + entries: Array< + | (EntryMatch & { id?: string }) + | (EntryMatchAny & { id?: string }) + | (EntryExists & { id?: string }) + >; +}; + +export type ExceptionListItemBuilderSchema = Omit & { + entries: BuilderEntry[]; +}; + +export type CreateExceptionListItemBuilderSchema = Omit< + CreateExceptionListItemSchema, + 'meta' | 'entries' +> & { + meta: { temporaryUuid: string }; + entries: BuilderEntry[]; +}; + +export type ExceptionsBuilderExceptionItem = + | ExceptionListItemBuilderSchema + | CreateExceptionListItemBuilderSchema; diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index c9938897b5093..d35fe5bb06c0c 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -38,3 +38,7 @@ export { UseExceptionListItemsSuccess, UseExceptionListsSuccess, } from './exceptions/types'; +export { BuilderEntryItem } from './exceptions/components/builder/entry_renderer'; +export { BuilderAndBadgeComponent } from './exceptions/components/builder/and_badge'; +export { BuilderEntryDeleteButtonComponent } from './exceptions/components/builder/entry_delete_button'; +export { BuilderExceptionListItemComponent } from './exceptions/components/builder/exception_item_renderer'; diff --git a/x-pack/plugins/lists/scripts/storybook.js b/x-pack/plugins/lists/scripts/storybook.js new file mode 100644 index 0000000000000..9a15d01b66af1 --- /dev/null +++ b/x-pack/plugins/lists/scripts/storybook.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'lists', + storyGlobs: [join(__dirname, '..', 'public', '**', '*.stories.tsx')], +}); diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 070ad6ee98f00..ecdf94a076809 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -298,3 +298,5 @@ export type RawValue = string | number | boolean | undefined | null; export type FieldFormatter = (value: RawValue) => string | number; export const INDEX_META_DATA_CREATED_BY = 'maps-drawing-data-ingest'; + +export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB diff --git a/x-pack/plugins/maps/common/types.ts b/x-pack/plugins/maps/common/types.ts index 806eac597ac57..6f2bd72c80896 100644 --- a/x-pack/plugins/maps/common/types.ts +++ b/x-pack/plugins/maps/common/types.ts @@ -22,3 +22,9 @@ export interface IndexSourceMappings { export interface BodySettings { [key: string]: any; } + +export interface WriteSettings { + index: string; + body: object; + [key: string]: any; +} diff --git a/x-pack/plugins/maps/server/create_doc_source.ts b/x-pack/plugins/maps/server/data_indexing/create_doc_source.ts similarity index 84% rename from x-pack/plugins/maps/server/create_doc_source.ts rename to x-pack/plugins/maps/server/data_indexing/create_doc_source.ts index 641a2acf42384..2b8984aa1534a 100644 --- a/x-pack/plugins/maps/server/create_doc_source.ts +++ b/x-pack/plugins/maps/server/data_indexing/create_doc_source.ts @@ -11,8 +11,8 @@ import { CreateDocSourceResp, IndexSourceMappings, BodySettings, -} from '../common'; -import { IndexPatternsService } from '../../../../src/plugins/data/common'; +} from '../../common'; +import { IndexPatternsCommonService } from '../../../../../src/plugins/data/server'; const DEFAULT_SETTINGS = { number_of_shards: 1 }; const DEFAULT_MAPPINGS = { @@ -25,16 +25,11 @@ export async function createDocSource( index: string, mappings: IndexSourceMappings, { asCurrentUser }: IScopedClusterClient, - indexPatternsService: IndexPatternsService + indexPatternsService: IndexPatternsCommonService ): Promise { try { await createIndex(index, mappings, asCurrentUser); - await indexPatternsService.createAndSave( - { - title: index, - }, - true - ); + await indexPatternsService.createAndSave({ title: index }, true); return { success: true, diff --git a/x-pack/plugins/maps/server/data_indexing/index_data.ts b/x-pack/plugins/maps/server/data_indexing/index_data.ts new file mode 100644 index 0000000000000..b87cd53a3dfd2 --- /dev/null +++ b/x-pack/plugins/maps/server/data_indexing/index_data.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ElasticsearchClient } from 'kibana/server'; +import { WriteSettings } from '../../common'; + +export async function writeDataToIndex( + index: string, + data: object, + asCurrentUser: ElasticsearchClient +) { + try { + const { body: indexExists } = await asCurrentUser.indices.exists({ index }); + if (!indexExists) { + throw new Error( + i18n.translate('xpack.maps.indexData.indexExists', { + defaultMessage: `Index: '{index}' not found. A valid index must be provided`, + values: { + index, + }, + }) + ); + } + const settings: WriteSettings = { index, body: data }; + const { body: resp } = await asCurrentUser.index(settings); + if (resp.result === 'Error') { + throw resp; + } else { + return { + success: true, + data, + }; + } + } catch (error) { + return { + success: false, + error, + }; + } +} diff --git a/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts new file mode 100644 index 0000000000000..e6e6471ff9af6 --- /dev/null +++ b/x-pack/plugins/maps/server/data_indexing/indexing_routes.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { Logger } from 'src/core/server'; +import { IRouter } from 'src/core/server'; +import type { DataRequestHandlerContext } from 'src/plugins/data/server'; +import { + INDEX_SOURCE_API_PATH, + GIS_API_PATH, + MAX_DRAWING_SIZE_BYTES, +} from '../../common/constants'; +import { createDocSource } from './create_doc_source'; +import { writeDataToIndex } from './index_data'; +import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server'; + +export function initIndexingRoutes({ + router, + logger, + dataPlugin, +}: { + router: IRouter; + logger: Logger; + dataPlugin: DataPluginStart; +}) { + router.post( + { + path: `/${INDEX_SOURCE_API_PATH}`, + validate: { + body: schema.object({ + index: schema.string(), + mappings: schema.any(), + }), + }, + options: { + body: { + accepts: ['application/json'], + }, + }, + }, + async (context, request, response) => { + const { index, mappings } = request.body; + const indexPatternsService = await dataPlugin.indexPatterns.indexPatternsServiceFactory( + context.core.savedObjects.client, + context.core.elasticsearch.client.asCurrentUser + ); + const result = await createDocSource( + index, + mappings, + context.core.elasticsearch.client, + indexPatternsService + ); + if (result.success) { + return response.ok({ body: result }); + } else { + if (result.error) { + logger.error(result.error); + } + return response.custom({ + body: result?.error?.message, + statusCode: 500, + }); + } + } + ); + + router.post( + { + path: `/${GIS_API_PATH}/feature`, + validate: { + body: schema.object({ + index: schema.string(), + data: schema.any(), + }), + }, + options: { + body: { + accepts: ['application/json'], + maxBytes: MAX_DRAWING_SIZE_BYTES, + }, + }, + }, + async (context, request, response) => { + const result = await writeDataToIndex( + request.body.index, + request.body.data, + context.core.elasticsearch.client.asCurrentUser + ); + if (result.success) { + return response.ok({ body: result }); + } else { + logger.error(result.error); + return response.custom({ + body: result.error.message, + statusCode: 500, + }); + } + } + ); +} diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index f18bb29ed453d..39ce9979870c5 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -24,8 +24,7 @@ import { INDEX_SETTINGS_API_PATH, FONTS_API_PATH, API_ROOT_PATH, - INDEX_SOURCE_API_PATH, -} from '../common/constants'; +} from '../common'; import { EMSClient } from '@elastic/ems-client'; import fetch from 'node-fetch'; import { i18n } from '@kbn/i18n'; @@ -34,7 +33,7 @@ import { schema } from '@kbn/config-schema'; import fs from 'fs'; import path from 'path'; import { initMVTRoutes } from './mvt/mvt_routes'; -import { createDocSource } from './create_doc_source'; +import { initIndexingRoutes } from './data_indexing/indexing_routes'; const EMPTY_EMS_CLIENT = { async getFileLayers() { @@ -594,47 +593,6 @@ export async function initRoutes( } ); - if (drawingFeatureEnabled) { - router.post( - { - path: `/${INDEX_SOURCE_API_PATH}`, - validate: { - body: schema.object({ - index: schema.string(), - mappings: schema.any(), - }), - }, - options: { - body: { - accepts: ['application/json'], - }, - }, - }, - async (context, request, response) => { - const { index, mappings } = request.body; - const indexPatternsService = await dataPlugin.indexPatterns.indexPatternsServiceFactory( - context.core.savedObjects.client, - context.core.elasticsearch.client.asCurrentUser - ); - const result = await createDocSource( - index, - mappings, - context.core.elasticsearch.client, - indexPatternsService - ); - if (result.success) { - return response.ok({ body: result }); - } else { - logger.error(result.error); - return response.custom({ - body: result.error.message, - statusCode: 500, - }); - } - } - ); - } - function checkEMSProxyEnabled() { const proxyEMSInMaps = emsSettings.isProxyElasticMapsServiceInMaps(); if (!proxyEMSInMaps) { @@ -666,4 +624,7 @@ export async function initRoutes( } initMVTRoutes({ router, logger }); + if (drawingFeatureEnabled) { + initIndexingRoutes({ router, logger, dataPlugin }); + } } diff --git a/x-pack/plugins/ml/common/types/es_client.ts b/x-pack/plugins/ml/common/types/es_client.ts index 0674ec6001159..f6db736db2519 100644 --- a/x-pack/plugins/ml/common/types/es_client.ts +++ b/x-pack/plugins/ml/common/types/es_client.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { SearchResponse, ShardsResponse } from 'elasticsearch'; +import type { SearchResponse, ShardsResponse } from 'elasticsearch'; +import { buildEsQuery } from '../../../../../src/plugins/data/common/es_query/es_query'; +import type { DslQuery } from '../../../../../src/plugins/data/common/es_query/kuery'; +import type { JsonObject } from '../../../../../src/plugins/kibana_utils/common'; export const HITS_TOTAL_RELATION = { EQ: 'eq', @@ -30,3 +33,5 @@ export interface SearchResponse7 { hits: SearchResponse7Hits; aggregations?: any; } + +export type InfluencersFilterQuery = ReturnType | DslQuery | JsonObject; diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 766b714abcc98..c7c3f3ae9b280 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -15,6 +15,7 @@ import { ML_PAGES } from '../constants/ml_url_generator'; import type { DataFrameAnalysisConfigType } from './data_frame_analytics'; import type { SearchQueryLanguage } from '../constants/search'; import type { ListingPageUrlState } from './common'; +import type { InfluencersFilterQuery } from './es_client'; type OptionalPageState = object | undefined; @@ -113,9 +114,9 @@ export interface ExplorerAppState { viewByFromPage?: number; }; mlExplorerFilter: { - influencersFilterQuery?: unknown; + influencersFilterQuery?: InfluencersFilterQuery; filterActive?: boolean; - filteredFields?: string[]; + filteredFields?: Array; queryString?: string; }; query?: any; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts index de1adfabcd7da..d8da7b8252771 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.ts +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -27,10 +27,18 @@ export enum ENTITY_FIELD_TYPE { PARTITON = 'partition', } +export const ENTITY_FIELD_OPERATIONS = { + ADD: '+', + REMOVE: '-', +} as const; + +export type EntityFieldOperation = typeof ENTITY_FIELD_OPERATIONS[keyof typeof ENTITY_FIELD_OPERATIONS]; + export interface EntityField { fieldName: string; fieldValue: string | number | undefined; fieldType?: ENTITY_FIELD_TYPE; + operation?: EntityFieldOperation; } // List of function descriptions for which actual values from record level results should be displayed. diff --git a/x-pack/plugins/ml/public/__mocks__/core_start.ts b/x-pack/plugins/ml/public/__mocks__/core_start.ts new file mode 100644 index 0000000000000..1fd988c887dda --- /dev/null +++ b/x-pack/plugins/ml/public/__mocks__/core_start.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { coreMock } from '../../../../../src/core/public/mocks'; +import { createMlStartDepsMock } from './ml_start_deps'; + +export const createCoreStartMock = () => + coreMock.createSetup({ pluginStartDeps: createMlStartDepsMock() }); diff --git a/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts new file mode 100644 index 0000000000000..77381c8728a48 --- /dev/null +++ b/x-pack/plugins/ml/public/__mocks__/ml_start_deps.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uiActionsPluginMock } from '../../../../../src/plugins/ui_actions/public/mocks'; +import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; +import { kibanaLegacyPluginMock } from '../../../../../src/plugins/kibana_legacy/public/mocks'; +import { embeddablePluginMock } from '../../../../../src/plugins/embeddable/public/mocks'; +import { lensPluginMock } from '../../../lens/public/mocks'; +import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; + +export const createMlStartDepsMock = () => ({ + data: dataPluginMock.createStartContract(), + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + kibanaLegacy: kibanaLegacyPluginMock.createStartContract(), + uiActions: uiActionsPluginMock.createStartContract(), + spaces: jest.fn(), + embeddable: embeddablePluginMock.createStartContract(), + maps: jest.fn(), + lens: lensPluginMock.createStartContract(), + triggersActionsUi: triggersActionsUiMock.createStart(), + fileUpload: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index e1c4e6b1e53d5..348c400b6d5a9 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -10,7 +10,7 @@ import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { usePageUrlState } from '../../../util/url_state'; -interface TableInterval { +export interface TableInterval { display: string; val: string; } @@ -64,8 +64,16 @@ export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] export const SelectInterval: FC = () => { const [interval, setInterval] = useTableInterval(); - const onChange = (e: React.ChangeEvent) => { - setInterval(optionValueToInterval(e.target.value)); + return ; +}; + +interface SelectIntervalUIProps { + interval: TableInterval; + onChange: (interval: TableInterval) => void; +} +export const SelectIntervalUI: FC = ({ interval, onChange }) => { + const handleOnChange = (e: React.ChangeEvent) => { + onChange(optionValueToInterval(e.target.value)); }; return ( @@ -73,7 +81,7 @@ export const SelectInterval: FC = () => { options={OPTIONS} className="ml-select-interval" value={interval.val} - onChange={onChange} + onChange={handleOnChange} /> ); }; diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index 22076c8215154..e8766ea16c002 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -38,7 +38,7 @@ const optionsMap = { [criticalLabel]: ANOMALY_THRESHOLD.CRITICAL, }; -interface TableSeverity { +export interface TableSeverity { val: number; display: string; color: string; @@ -67,7 +67,7 @@ export const SEVERITY_OPTIONS: TableSeverity[] = [ }, ]; -function optionValueToThreshold(value: number) { +export function optionValueToThreshold(value: number) { // Get corresponding threshold object with required display and val properties from the specified value. let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); @@ -121,17 +121,26 @@ interface Props { export const SelectSeverity: FC = ({ classNames } = { classNames: '' }) => { const [severity, setSeverity] = useTableSeverity(); - const onChange = (valueDisplay: string) => { - setSeverity(optionValueToThreshold(optionsMap[valueDisplay])); + return ; +}; + +export const SelectSeverityUI: FC<{ + classNames?: string; + severity: TableSeverity; + onChange: (s: TableSeverity) => void; +}> = ({ classNames = '', severity, onChange }) => { + const handleOnChange = (valueDisplay: string) => { + onChange(optionValueToThreshold(optionsMap[valueDisplay])); }; return ( ); }; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts new file mode 100644 index 0000000000000..337a49ada6f31 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_time_buckets.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useUiSettings } from '../../contexts/kibana'; +import { TimeBuckets } from '../../util/time_buckets'; +import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; + +export const useTimeBuckets = () => { + const uiSettings = useUiSettings(); + return useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); +}; diff --git a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js index 9a580c179001d..d52d22f6b4aa7 100644 --- a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js +++ b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js @@ -324,7 +324,7 @@ export function CustomSelectionTable({ isSelected={isItemSelected(item[tableItemId])} isSelectable={true} hasActions={true} - data-test-subj="mlCustomSelectionTableRow" + data-test-subj={`mlCustomSelectionTableRow row-${item[tableItemId]}`} > {cells} diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx index 650a9d3deb539..a79c8a63b3bc6 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EMPTY_FIELD_VALUE_LABEL } from '../../timeseriesexplorer/components/entity_control/entity_control'; import { MLCATEGORY } from '../../../../common/constants/field_types'; +import { ENTITY_FIELD_OPERATIONS } from '../../../../common/util/anomaly_utils'; export type EntityCellFilter = ( entityName: string, @@ -40,7 +41,7 @@ function getAddFilter({ entityName, entityValue, filter }: EntityCellProps) { filter(entityName, entityValue, '+')} + onClick={() => filter(entityName, entityValue, ENTITY_FIELD_OPERATIONS.ADD)} iconType="plusInCircle" aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.addFilterAriaLabel', { defaultMessage: 'Add filter', @@ -65,7 +66,7 @@ function getRemoveFilter({ entityName, entityValue, filter }: EntityCellProps) { filter(entityName, entityValue, '-')} + onClick={() => filter(entityName, entityValue, ENTITY_FIELD_OPERATIONS.REMOVE)} iconType="minusInCircle" aria-label={i18n.translate('xpack.ml.anomaliesTable.entityCell.removeFilterAriaLabel', { defaultMessage: 'Remove filter', diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts index 80575118e71dc..a1d846c065dce 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/kibana_context.ts @@ -5,12 +5,21 @@ * 2.0. */ -export const useMlKibana = jest.fn(() => { - return { - services: { - application: { - navigateToApp: jest.fn(), +export const kibanaContextMock = { + services: { + chrome: { recentlyAccessed: { add: jest.fn() } }, + application: { navigateToApp: jest.fn() }, + http: { + basePath: { + get: jest.fn(), }, }, - }; + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + }, +}; + +export const useMlKibana = jest.fn(() => { + return kibanaContextMock; }); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts index dbf78f314b78d..a9ee49fcbadd8 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts @@ -7,7 +7,7 @@ import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; -const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter; +export const timefilterMock = dataPluginMock.createStartContract().query.timefilter.timefilter; export const useTimefilter = jest.fn(() => { return timefilterMock; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 258ffc887325d..e09e9f3d2c1ae 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -13,7 +13,6 @@ import { forkJoin, of, Observable, Subject } from 'rxjs'; import { mergeMap, switchMap, tap } from 'rxjs/operators'; import { useCallback, useMemo } from 'react'; -import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { explorerService } from '../explorer_dashboard_service'; import { getDateFormatTz, @@ -31,10 +30,14 @@ import { import { ExplorerState } from '../reducers'; import { useMlKibana, useTimefilter } from '../../contexts/kibana'; import { AnomalyTimelineService } from '../../services/anomaly_timeline_service'; -import { mlResultsServiceProvider } from '../../services/results_service'; +import { MlResultsService, mlResultsServiceProvider } from '../../services/results_service'; import { isViewBySwimLaneData } from '../swimlane_container'; import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants'; import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; +import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; +import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; +import { mlJobService } from '../../services/job_service'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -52,7 +55,6 @@ const memoize = any>(func: T, context?: any) => { return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual); }; -const memoizedAnomalyDataChange = memoize(anomalyDataChange); const memoizedLoadAnnotationsTableData = memoize( loadAnnotationsTableData ); @@ -64,7 +66,7 @@ const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); export interface LoadExplorerDataConfig { - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; lastRefresh: number; noInfluencersConfigured: boolean; selectedCells: AppStateSelectedCells | undefined; @@ -92,7 +94,9 @@ export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfi * Fetches the data necessary for the Anomaly Explorer using observables. */ const loadExplorerDataProvider = ( + mlResultsService: MlResultsService, anomalyTimelineService: AnomalyTimelineService, + anomalyExplorerService: AnomalyExplorerChartsService, timefilter: TimefilterContract ) => { const memoizedLoadOverallData = memoize( @@ -103,6 +107,11 @@ const loadExplorerDataProvider = ( anomalyTimelineService.loadViewBySwimlane, anomalyTimelineService ); + const memoizedAnomalyDataChange = memoize( + anomalyExplorerService.getAnomalyData, + anomalyExplorerService + ); + return (config: LoadExplorerDataConfig): Observable> => { if (!isLoadExplorerDataConfig(config)) { return of({}); @@ -124,6 +133,10 @@ const loadExplorerDataProvider = ( viewByPerPage, } = config; + const combinedJobRecords: Record = selectedJobs.reduce((acc, job) => { + return { ...acc, [job.id]: mlJobService.getJob(job.id) }; + }, {}); + const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = getSelectionJobIds(selectedCells, selectedJobs); @@ -149,6 +162,7 @@ const loadExplorerDataProvider = ( ), anomalyChartRecords: memoizedLoadDataForCharts( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -160,6 +174,7 @@ const loadExplorerDataProvider = ( selectionInfluencers.length === 0 ? memoizedLoadTopInfluencers( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -200,23 +215,29 @@ const loadExplorerDataProvider = ( // and pass on the data we already fetched. tap(explorerService.setViewBySwimlaneLoading), // Trigger a side-effect to update the charts. - tap(({ anomalyChartRecords }) => { + tap(({ anomalyChartRecords, topFieldValues }) => { if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { memoizedAnomalyDataChange( lastRefresh, + explorerService, + combinedJobRecords, swimlaneContainerWidth, anomalyChartRecords, timerange.earliestMs, timerange.latestMs, + timefilter, tableSeverity ); } else { memoizedAnomalyDataChange( lastRefresh, + explorerService, + combinedJobRecords, swimlaneContainerWidth, [], timerange.earliestMs, timerange.latestMs, + timefilter, tableSeverity ); } @@ -234,6 +255,7 @@ const loadExplorerDataProvider = ( anomalyChartRecords.length > 0 ? memoizedLoadFilteredTopInfluencers( lastRefresh, + mlResultsService, jobIds, timerange.earliestMs, timerange.latestMs, @@ -291,12 +313,23 @@ export const useExplorerData = (): [Partial | undefined, (d: any) } = useMlKibana(); const loadExplorerData = useMemo(() => { + const mlResultsService = mlResultsServiceProvider(mlApiServices); const anomalyTimelineService = new AnomalyTimelineService( timefilter, uiSettings, - mlResultsServiceProvider(mlApiServices) + mlResultsService + ); + const anomalyExplorerService = new AnomalyExplorerChartsService( + timefilter, + mlApiServices, + mlResultsService + ); + return loadExplorerDataProvider( + mlResultsService, + anomalyTimelineService, + anomalyExplorerService, + timefilter ); - return loadExplorerDataProvider(anomalyTimelineService, timefilter); }, []); const loadExplorerData$ = useMemo(() => new Subject(), []); diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 2330eafd87825..8fe2c32b766b4 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -25,7 +25,7 @@ import { EuiInMemoryTable } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useMlKibana } from '../contexts/kibana'; import { DashboardSavedObject } from '../../../../../../src/plugins/dashboard/public'; -import { getDefaultPanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { getDefaultSwimlanePanelTitle } from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { useDashboardService } from '../services/dashboard_service'; import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; import { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -40,10 +40,10 @@ export interface DashboardItem { export type EuiTableProps = EuiInMemoryTableProps; -function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { +function getDefaultEmbeddablePanelConfig(jobIds: JobId[]) { return { type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, - title: getDefaultPanelTitle(jobIds), + title: getDefaultSwimlanePanelTitle(jobIds), }; } @@ -129,7 +129,7 @@ export const AddToDashboardControl: FC = ({ for (const selectedDashboard of selectedItems) { const panelsData = swimlanes.map((swimlaneType) => { - const config = getDefaultEmbeddablepaPanelConfig(jobIds); + const config = getDefaultEmbeddablePanelConfig(jobIds); if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { return { ...config, diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx index 33ab2b227009c..6f5ae5e17590a 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx @@ -17,6 +17,7 @@ import { import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; import { SEARCH_QUERY_LANGUAGE, ErrorMessage } from '../../../../../common/constants/search'; import { explorerService } from '../../explorer_dashboard_service'; +import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export const DEFAULT_QUERY_LANG = SEARCH_QUERY_LANGUAGE.KUERY; @@ -29,7 +30,7 @@ export function getKqlQueryValues({ queryLanguage: string; indexPattern: IIndexPattern; }): { clearSettings: boolean; settings: any } { - let influencersFilterQuery: any = {}; + let influencersFilterQuery: InfluencersFilterQuery = {}; const filteredFields: string[] = []; const ast = esKuery.fromKueryExpression(inputString); const isAndOperator = ast && ast.function === 'and'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index abf8197f51634..6979277c43077 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -68,8 +68,10 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta // Anomalies Table import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; -import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; +import { getToastNotifications } from '../util/dependency_cache'; import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; +import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; const ExplorerPage = ({ children, @@ -136,7 +138,7 @@ const ExplorerPage = ({
); -export class Explorer extends React.Component { +export class ExplorerUI extends React.Component { static propTypes = { explorerState: PropTypes.object.isRequired, setSelectedCells: PropTypes.func.isRequired, @@ -224,7 +226,22 @@ export class Explorer extends React.Component { updateLanguage = (language) => this.setState({ language }); render() { - const { showCharts, severity, stoppedPartitions, selectedJobsRunning } = this.props; + const { + share: { + urlGenerators: { getUrlGenerator }, + }, + } = this.props.kibana.services; + + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + + const { + showCharts, + severity, + stoppedPartitions, + selectedJobsRunning, + timefilter, + timeBuckets, + } = this.props; const { annotations, @@ -274,7 +291,6 @@ export class Explorer extends React.Component { const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; return ( @@ -460,7 +476,18 @@ export class Explorer extends React.Component {
- {showCharts && } + {showCharts && ( + + )}
diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss new file mode 100644 index 0000000000000..732b71d056536 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.scss @@ -0,0 +1,14 @@ +.filter-button { + opacity: .3; + min-width: 14px; + padding-right: 0; + + .euiIcon { + width: $euiFontSizeXS; + height: $euiFontSizeXS; + } +} + +.filter-button:hover { + opacity: 1; +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx new file mode 100644 index 0000000000000..079af5827a4b5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/entity_filter.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { FC } from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + ENTITY_FIELD_OPERATIONS, + EntityFieldOperation, +} from '../../../../../../../common/util/anomaly_utils'; +import './entity_filter.scss'; + +interface EntityFilterProps { + onFilter: (params: { + influencerFieldName: string; + influencerFieldValue: string; + action: EntityFieldOperation; + }) => void; + influencerFieldName: string; + influencerFieldValue: string; +} +export const EntityFilter: FC = ({ + onFilter, + influencerFieldName, + influencerFieldValue, +}) => { + return ( + + + } + > + + onFilter({ + influencerFieldName, + influencerFieldValue, + action: ENTITY_FIELD_OPERATIONS.ADD, + }) + } + iconType="plusInCircle" + aria-label={i18n.translate('xpack.ml.entityFilter.addFilterAriaLabel', { + defaultMessage: 'Add filter for {influencerFieldName} {influencerFieldValue}', + values: { influencerFieldName, influencerFieldValue }, + })} + /> + + + } + > + + onFilter({ + influencerFieldName, + influencerFieldValue, + action: ENTITY_FIELD_OPERATIONS.REMOVE, + }) + } + iconType="minusInCircle" + aria-label={i18n.translate('xpack.ml.entityFilter.removeFilterAriaLabel', { + defaultMessage: 'Remove filter for {influencerFieldName} {influencerFieldValue}', + values: { influencerFieldName, influencerFieldValue }, + })} + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/index.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/index.ts new file mode 100644 index 0000000000000..69e1a632b5ffd --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/entity_filter/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 { EntityFilter } from './entity_filter'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js index 97eb73906e8de..ad07d1a75bdb5 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/components/explorer_chart_label/explorer_chart_label.js @@ -7,18 +7,20 @@ import './_explorer_chart_label.scss'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Fragment, useCallback } from 'react'; import { EuiIconTip } from '@elastic/eui'; import { ExplorerChartLabelBadge } from './explorer_chart_label_badge'; import { ExplorerChartInfoTooltip } from '../../explorer_chart_info_tooltip'; +import { EntityFilter } from './entity_filter'; export function ExplorerChartLabel({ detectorLabel, entityFields, infoTooltip, wrapLabel = false, + onSelectEntity, }) { // Depending on whether we wrap the entityField badges to a new line, we render this differently: // @@ -37,9 +39,27 @@ export function ExplorerChartLabel({  –  ); - const entityFieldBadges = entityFields.map((entity) => ( - - )); + const applyFilter = useCallback( + ({ influencerFieldName, influencerFieldValue, action }) => + onSelectEntity(influencerFieldName, influencerFieldValue, action), + [onSelectEntity] + ); + + const entityFieldBadges = entityFields.map((entity) => { + const key = `${infoTooltip.chartFunction}-${entity.fieldName}-${entity.fieldType}-${entity.fieldValue}`; + return ( + + + {onSelectEntity !== undefined && ( + + )} + + ); + }); const infoIcon = ( diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx new file mode 100644 index 0000000000000..d1e22ef21de25 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_anomalies_container.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { ExplorerChartsContainer } from './explorer_charts_container'; +import { + SelectSeverityUI, + TableSeverity, +} from '../../components/controls/select_severity/select_severity'; +import type { UrlGeneratorContract } from '../../../../../../../src/plugins/share/public'; +import type { TimeBuckets } from '../../util/time_buckets'; +import type { TimefilterContract } from '../../../../../../../src/plugins/data/public'; +import type { EntityFieldOperation } from '../../../../common/util/anomaly_utils'; +import type { ExplorerChartsData } from './explorer_charts_container_service'; + +interface ExplorerAnomaliesContainerProps { + id: string; + chartsData: ExplorerChartsData; + showCharts: boolean; + severity: TableSeverity; + setSeverity: (severity: TableSeverity) => void; + mlUrlGenerator: UrlGeneratorContract<'ML_APP_URL_GENERATOR'>; + timeBuckets: TimeBuckets; + timefilter: TimefilterContract; + onSelectEntity: (fieldName: string, fieldValue: string, operation: EntityFieldOperation) => void; +} + +const tooManyBucketsCalloutMsg = i18n.translate( + 'xpack.ml.explorer.charts.dashboardTooManyBucketsDescription', + { + defaultMessage: + 'This selection contains too many buckets to be displayed. You should shorten the time range of the view.', + } +); + +export const ExplorerAnomaliesContainer: FC = ({ + id, + chartsData, + showCharts, + severity, + setSeverity, + mlUrlGenerator, + timeBuckets, + timefilter, + onSelectEntity, +}) => { + return ( + <> + + + + + + + + + + {Array.isArray(chartsData.seriesToPlot) && + chartsData.seriesToPlot.length === 0 && + chartsData.errorMessages === undefined && ( + +

+ +

+
+ )} +
+ {showCharts && ( + + )} +
+ + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 4607ac65c87a6..fa6d8acfb0e00 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -33,7 +33,6 @@ import { chartExtendedLimits, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -63,7 +62,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets, tooltipService, timeBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -263,7 +262,6 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index ca8d832e6b43b..11a15b192fc52 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -7,18 +7,6 @@ import { chartData as mockChartData } from './__mocks__/mock_chart_data_rare'; import seriesConfig from './__mocks__/mock_series_config_rare.json'; - -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -30,7 +18,10 @@ import React from 'react'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { chartLimits } from '../../util/chart_utils'; - +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; +const utilityProps = { + timeBuckets: timeBucketsMock, +}; describe('ExplorerChart', () => { const mlSelectSeverityServiceMock = { state: { @@ -55,6 +46,7 @@ describe('ExplorerChart', () => { ); @@ -80,6 +72,7 @@ describe('ExplorerChart', () => { seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} + {...utilityProps} /> ); @@ -112,6 +105,7 @@ describe('ExplorerChart', () => { seriesConfig={config} mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} + {...utilityProps} />
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index d2d81e0349c68..39a3f83961d3a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,7 +38,6 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; const CONTENT_WRAPPER_HEIGHT = 215; @@ -50,6 +49,7 @@ export class ExplorerChartSingleMetric extends React.Component { seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, tooltipService: PropTypes.object.isRequired, + timeBuckets: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets, tooltipService, timeBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -188,7 +188,6 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 25b2251b45435..981f7515d3d70 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -7,18 +7,6 @@ import { chartData as mockChartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; - -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -30,6 +18,11 @@ import React from 'react'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; import { chartLimits } from '../../util/chart_utils'; +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; + +const utilityProps = { + timeBuckets: timeBucketsMock, +}; describe('ExplorerChart', () => { const mlSelectSeverityServiceMock = { @@ -56,6 +49,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} /> ); @@ -82,6 +76,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} /> ); @@ -115,6 +110,7 @@ describe('ExplorerChart', () => { mlSelectSeverityService={mlSelectSeverityServiceMock} tooltipService={mockTooltipService} severity={0} + {...utilityProps} />
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 5bdae2427c103..2432c6671827e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,8 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import './_index.scss'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiButtonEmpty, @@ -23,7 +24,6 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; -import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -31,15 +31,15 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; - +import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: 'This selection contains too many buckets to be displayed. You should shorten the time range of the view or narrow the selection in the timeline.', }); + const textViewButton = i18n.translate( 'xpack.ml.explorer.charts.openInSingleMetricViewerButtonLabel', { @@ -67,14 +67,23 @@ function ExplorerChartContainer({ wrapLabel, mlUrlGenerator, basePath, + timeBuckets, + timefilter, + onSelectEntity, + recentlyAccessed, + tooManyBucketsCalloutMsg, }) { - const [explorerSeriesLink, setExplorerSeriesLink] = useState(); + const [explorerSeriesLink, setExplorerSeriesLink] = useState(''); useEffect(() => { let isCancelled = false; const generateLink = async () => { if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); + const singleMetricViewerLink = await getExploreSeriesLink( + mlUrlGenerator, + series, + timefilter + ); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -85,8 +94,15 @@ function ExplorerChartContainer({ }, [mlUrlGenerator, series]); const addToRecentlyAccessed = useCallback(() => { - addItemToRecentlyAccessed('timeseriesexplorer', series.jobId, explorerSeriesLink); - }, [explorerSeriesLink]); + if (recentlyAccessed) { + addItemToRecentlyAccessed( + 'timeseriesexplorer', + series.jobId, + explorerSeriesLink, + recentlyAccessed + ); + } + }, [explorerSeriesLink, recentlyAccessed]); const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -121,6 +137,7 @@ function ExplorerChartContainer({ entityFields={entityFields} infoTooltip={{ ...series.infoTooltip, chartType }} wrapLabel={wrapLabel} + onSelectEntity={onSelectEntity} /> @@ -128,7 +145,7 @@ function ExplorerChartContainer({ {tooManyBuckets && ( ); } + if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -176,6 +194,7 @@ function ExplorerChartContainer({ {(tooltipService) => ( {(tooltipService) => ( { const { services: { + chrome: { recentlyAccessed }, http: { basePath }, - share: { - urlGenerators: { getUrlGenerator }, - }, embeddable: embeddablePlugin, maps: mapsPlugin, }, @@ -244,8 +267,6 @@ export const ExplorerChartsContainerUI = ({ const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; - const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); - // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; @@ -270,6 +291,11 @@ export const ExplorerChartsContainerUI = ({ wrapLabel={wrapLabel} mlUrlGenerator={mlUrlGenerator} basePath={basePath.get()} + timeBuckets={timeBuckets} + timefilter={timefilter} + onSelectEntity={onSelectEntity} + recentlyAccessed={recentlyAccessed} + tooManyBucketsCalloutMsg={tooManyBucketsCalloutMsg} /> ))} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index 70b46046aa7ce..53d06e7253f00 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -18,18 +18,10 @@ import { ExplorerChartsContainer } from './explorer_charts_container'; import { chartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; import seriesConfigRare from './__mocks__/mock_series_config_rare.json'; +import { kibanaContextMock } from '../../contexts/kibana/__mocks__/kibana_context'; +import { timeBucketsMock } from '../../util/__mocks__/time_buckets'; +import { timefilterMock } from '../../contexts/kibana/__mocks__/use_timefilter'; -// Mock TimeBuckets and mlFieldFormatService, they don't play well -// with the jest based test setup yet. -jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), -})); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { getFieldFormat: jest.fn(), @@ -47,6 +39,18 @@ jest.mock('../../../../../../../src/plugins/kibana_react/public', () => ({ }, })); +const getUtilityProps = () => { + const mlUrlGenerator = { + createUrl: jest.fn(), + }; + return { + mlUrlGenerator, + timefilter: timefilterMock, + timeBuckets: timeBucketsMock, + kibana: kibanaContextMock, + }; +}; + describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; const originalGetBBox = SVGElement.prototype.getBBox; @@ -54,27 +58,10 @@ describe('ExplorerChartsContainer', () => { beforeEach(() => (SVGElement.prototype.getBBox = () => mockedGetBBox)); afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); - const kibanaContextMock = { - services: { - application: { navigateToApp: jest.fn() }, - http: { - basePath: { - get: jest.fn(), - }, - }, - share: { - urlGenerators: { getUrlGenerator: jest.fn() }, - }, - }, - }; test('Minimal Initialization', () => { const wrapper = shallow( - + ); @@ -99,7 +86,7 @@ describe('ExplorerChartsContainer', () => { }; const wrapper = mount( - + ); @@ -127,7 +114,7 @@ describe('ExplorerChartsContainer', () => { }; const wrapper = mount( - + ); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts deleted file mode 100644 index a384a38899587..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { JobId } from '../../../../common/types/anomaly_detection_jobs'; - -export interface ExplorerChartSeriesErrorMessages { - [key: string]: Set; -} -export declare interface ExplorerChartsData { - chartsPerRow: number; - seriesToPlot: any[]; - tooManyBuckets: boolean; - timeFieldName: string; - errorMessages: ExplorerChartSeriesErrorMessages; -} - -export declare const getDefaultChartsData: () => ExplorerChartsData; - -export declare const anomalyDataChange: ( - chartsContainerWidth: number, - anomalyRecords: any[], - earliestMs: number, - latestMs: number, - severity?: number -) => void; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js deleted file mode 100644 index 7eef548bc2d1c..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ /dev/null @@ -1,765 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * Service for the container for the anomaly charts in the - * Machine Learning Explorer dashboard. - * The service processes the data required to draw each of the charts - * and manages the layout of the charts in the containing div. - */ - -import { get, each, find, sortBy, map, reduce } from 'lodash'; - -import { buildConfig } from './explorer_chart_config_builder'; -import { chartLimits, getChartType } from '../../util/chart_utils'; -import { getTimefilter } from '../../util/dependency_cache'; - -import { getEntityFieldList } from '../../../../common/util/anomaly_utils'; -import { - isSourceDataChartableForDetector, - isModelPlotChartableForDetector, - isModelPlotEnabled, - isMappableJob, -} from '../../../../common/util/job_utils'; -import { mlResultsService } from '../../services/results_service'; -import { mlJobService } from '../../services/job_service'; -import { explorerService } from '../explorer_dashboard_service'; - -import { CHART_TYPE } from '../explorer_constants'; -import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { i18n } from '@kbn/i18n'; -import { SWIM_LANE_LABEL_WIDTH } from '../swimlane_container'; - -export function getDefaultChartsData() { - return { - chartsPerRow: 1, - errorMessages: undefined, - seriesToPlot: [], - // default values, will update on every re-render - tooManyBuckets: false, - timeFieldName: 'timestamp', - }; -} - -const CHART_MAX_POINTS = 500; -const ANOMALIES_MAX_RESULTS = 500; -const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. -const ML_TIME_FIELD_NAME = 'timestamp'; -const USE_OVERALL_CHART_LIMITS = false; -const MAX_CHARTS_PER_ROW = 4; - -export const anomalyDataChange = function ( - chartsContainerWidth, - anomalyRecords, - selectedEarliestMs, - selectedLatestMs, - severity = 0 -) { - const data = getDefaultChartsData(); - - const containerWith = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH; - - const filteredRecords = anomalyRecords.filter((record) => { - return Number(record.record_score) >= severity; - }); - const [allSeriesRecords, errorMessages] = processRecordsForDisplay(filteredRecords); - // Calculate the number of charts per row, depending on the width available, to a max of 4. - let chartsPerRow = Math.min(Math.max(Math.floor(containerWith / 550), 1), MAX_CHARTS_PER_ROW); - if (allSeriesRecords.length === 1) { - chartsPerRow = 1; - } - - data.chartsPerRow = chartsPerRow; - - // Build the data configs of the anomalies to be displayed. - // TODO - implement paging? - // For now just take first 6 (or 8 if 4 charts per row). - const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); - const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); - const hasGeoData = recordsToPlot.find( - (record) => - (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG - ); - - const seriesConfigs = recordsToPlot.map(buildConfig); - const seriesConfigsNoGeoData = []; - - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - - const mapData = []; - - if (hasGeoData !== undefined) { - for (let i = 0; i < seriesConfigs.length; i++) { - const config = seriesConfigs[i]; - let records; - if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { - if (config.entityFields.length) { - records = [ - recordsToPlot.find((record) => { - const entityFieldName = config.entityFields[0].fieldName; - const entityFieldValue = config.entityFields[0].fieldValue; - return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; - }), - ]; - } else { - records = recordsToPlot; - } - - mapData.push({ - ...config, - loading: false, - mapData: records, - }); - } else { - seriesConfigsNoGeoData.push(config); - } - } - } - - // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. - data.tooManyBuckets = false; - const chartWidth = Math.floor(containerWith / chartsPerRow); - const { chartRange, tooManyBuckets } = calculateChartRange( - seriesConfigs, - selectedEarliestMs, - selectedLatestMs, - chartWidth, - recordsToPlot, - data.timeFieldName - ); - data.tooManyBuckets = tooManyBuckets; - - data.errorMessages = errorMessages; - - explorerService.setCharts({ ...data }); - - if (seriesConfigs.length === 0) { - return data; - } - - // Query 1 - load the raw metric data. - function getMetricData(config, range) { - const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config; - - const job = mlJobService.getJob(jobId); - - // If the job uses aggregation or scripted fields, and if it's a config we don't support - // use model plot data if model plot is enabled - // else if source data can be plotted, use that, otherwise model plot will be available. - const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); - if (useSourceData === true) { - const datafeedQuery = get(config, 'datafeedConfig.query', null); - return mlResultsService - .getMetricData( - config.datafeedConfig.indices, - entityFields, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.summaryCountFieldName, - config.timeField, - range.min, - range.max, - bucketSpanSeconds * 1000, - config.datafeedConfig - ) - .toPromise(); - } else { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (detector.partition_field_name !== undefined) { - const partitionEntity = find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } - } - - if (detector.over_field_name !== undefined) { - const overEntity = find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } - } - - if (detector.by_field_name !== undefined) { - const byEntity = find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } - } - - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - return mlResultsService - .getModelPlotOutput( - jobId, - detectorIndex, - criteriaFields, - range.min, - range.max, - bucketSpanSeconds * 1000 - ) - .toPromise() - .then((resp) => { - // Return data in format required by the explorer charts. - const results = resp.results; - Object.keys(results).forEach((time) => { - obj.results[time] = results[time].actual; - }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); - } - } - - // Query 2 - load the anomalies. - // Criteria to return the records for this series are the detector_index plus - // the specific combination of 'entity' fields i.e. the partition / by / over fields. - function getRecordsForCriteria(config, range) { - let criteria = []; - criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); - criteria = criteria.concat(config.entityFields); - return mlResultsService - .getRecordsForCriteria( - [config.jobId], - criteria, - 0, - range.min, - range.max, - ANOMALIES_MAX_RESULTS - ) - .toPromise(); - } - - // Query 3 - load any scheduled events for the job. - function getScheduledEvents(config, range) { - return mlResultsService - .getScheduledEventsByBucket( - [config.jobId], - range.min, - range.max, - config.bucketSpanSeconds * 1000, - 1, - MAX_SCHEDULED_EVENTS - ) - .toPromise(); - } - - // Query 4 - load context data distribution - function getEventDistribution(config, range) { - const chartType = getChartType(config); - - let splitField; - let filterField = null; - - // Define splitField and filterField based on chartType - if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { - splitField = config.entityFields.find((f) => f.fieldType === 'by'); - filterField = config.entityFields.find((f) => f.fieldType === 'partition'); - } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { - splitField = config.entityFields.find((f) => f.fieldType === 'over'); - filterField = config.entityFields.find((f) => f.fieldType === 'partition'); - } - - const datafeedQuery = get(config, 'datafeedConfig.query', null); - return mlResultsService.getEventDistributionData( - config.datafeedConfig.indices, - splitField, - filterField, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, - range.min, - range.max, - config.bucketSpanSeconds * 1000 - ); - } - - // first load and wait for required data, - // only after that trigger data processing and page render. - // TODO - if query returns no results e.g. source data has been deleted, - // display a message saying 'No data between earliest/latest'. - const seriesPromises = []; - // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses - const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; - seriesCongifsForPromises.forEach((seriesConfig) => { - seriesPromises.push( - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); - }); - - function processChartData(response, seriesIndex) { - const metricData = response[0].results; - const records = response[1].records; - const jobId = seriesCongifsForPromises[seriesIndex].jobId; - const scheduledEvents = response[2].events[jobId]; - const eventDistribution = response[3]; - const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); - - // Sort records in ascending time order matching up with chart data - records.sort((recordA, recordB) => { - return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; - }); - - // Return dataset in format used by the chart. - // i.e. array of Objects with keys date (timestamp), value, - // plus anomalyScore for points with anomaly markers. - let chartData = []; - if (metricData !== undefined) { - if (eventDistribution.length > 0 && records.length > 0) { - const filterField = records[0].by_field_value || records[0].over_field_value; - chartData = eventDistribution.filter((d) => d.entity !== filterField); - map(metricData, (value, time) => { - // The filtering for rare/event_distribution charts needs to be handled - // differently because of how the source data is structured. - // For rare chart values we are only interested wether a value is either `0` or not, - // `0` acts like a flag in the chart whether to display the dot/marker. - // All other charts (single metric, population) are metric based and with - // those a value of `null` acts as the flag to hide a data point. - if ( - (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || - (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) - ) { - chartData.push({ - date: +time, - value: value, - entity: filterField, - }); - } - }); - } else { - chartData = map(metricData, (value, time) => ({ - date: +time, - value: value, - })); - } - } - - // Iterate through the anomaly records, adding anomalyScore properties - // to the chartData entries for anomalous buckets. - const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); - each(records, (record) => { - // Look for a chart point with the same time as the record. - // If none found, insert a point for anomalies due to a gap in the data. - const recordTime = record[ML_TIME_FIELD_NAME]; - let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); - if (chartPoint === undefined) { - chartPoint = { date: new Date(recordTime), value: null }; - chartData.push(chartPoint); - } - - chartPoint.anomalyScore = record.record_score; - - if (record.actual !== undefined) { - chartPoint.actual = record.actual; - chartPoint.typical = record.typical; - } else { - const causes = get(record, 'causes', []); - if (causes.length > 0) { - chartPoint.byFieldName = record.by_field_name; - chartPoint.numberOfCauses = causes.length; - if (causes.length === 1) { - // If only a single cause, copy actual and typical values to the top level. - const cause = record.causes[0]; - chartPoint.actual = cause.actual; - chartPoint.typical = cause.typical; - } - } - } - - if (record.multi_bucket_impact !== undefined) { - chartPoint.multiBucketImpact = record.multi_bucket_impact; - } - }); - - // Add a scheduledEvents property to any points in the chart data set - // which correspond to times of scheduled events for the job. - if (scheduledEvents !== undefined) { - each(scheduledEvents, (events, time) => { - const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); - if (chartPoint !== undefined) { - // Note if the scheduled event coincides with an absence of the underlying metric data, - // we don't worry about plotting the event. - chartPoint.scheduledEvents = events; - } - }); - } - - return chartData; - } - - function getChartDataForPointSearch(chartData, record, chartType) { - if ( - chartType === CHART_TYPE.EVENT_DISTRIBUTION || - chartType === CHART_TYPE.POPULATION_DISTRIBUTION - ) { - return chartData.filter((d) => { - return d.entity === (record && (record.by_field_value || record.over_field_value)); - }); - } - - return chartData; - } - - function findChartPointForTime(chartData, time) { - return chartData.find((point) => point.date === time); - } - - Promise.all(seriesPromises) - .then((response) => { - // calculate an overall min/max for all series - const processedData = response.map(processChartData); - const allDataPoints = reduce( - processedData, - (datapoints, series) => { - each(series, (d) => datapoints.push(d)); - return datapoints; - }, - [] - ); - const overallChartLimits = chartLimits(allDataPoints); - - data.seriesToPlot = response.map((d, i) => { - return { - ...seriesCongifsForPromises[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS - ? overallChartLimits - : chartLimits(processedData[i]), - }; - }); - - if (mapData.length) { - // push map data in if it's available - data.seriesToPlot.push(...mapData); - } - explorerService.setCharts({ ...data }); - }) - .catch((error) => { - console.error(error); - }); -}; - -function processRecordsForDisplay(anomalyRecords) { - // Aggregate the anomaly data by detector, and entity (by/over/partition). - if (anomalyRecords.length === 0) { - return [[], undefined]; - } - - // Aggregate by job, detector, and analysis fields (partition, by, over). - const aggregatedData = {}; - - const jobsErrorMessage = {}; - each(anomalyRecords, (record) => { - // Check if we can plot a chart for this record, depending on whether the source data - // is chartable, and if model plot is enabled for the job. - const job = mlJobService.getJob(record.job_id); - - // if we already know this job has datafeed aggregations we cannot support - // no need to do more checks - if (jobsErrorMessage[record.job_id] !== undefined) { - return; - } - - let isChartable = - isSourceDataChartableForDetector(job, record.detector_index) || - isMappableJob(job, record.detector_index); - - if (isChartable === false) { - if (isModelPlotChartableForDetector(job, record.detector_index)) { - // Check if model plot is enabled for this job. - // Need to check the entity fields for the record in case the model plot config has a terms list. - const entityFields = getEntityFieldList(record); - if (isModelPlotEnabled(job, record.detector_index, entityFields)) { - isChartable = true; - } else { - isChartable = false; - jobsErrorMessage[record.job_id] = i18n.translate( - 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage', - { - defaultMessage: - 'source data is not viewable for this detector and model plot is disabled', - } - ); - } - } else { - jobsErrorMessage[record.job_id] = i18n.translate( - 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage', - { - defaultMessage: 'both source data and model plot are not chartable for this detector', - } - ); - } - } - - if (isChartable === false) { - return; - } - const jobId = record.job_id; - if (aggregatedData[jobId] === undefined) { - aggregatedData[jobId] = {}; - } - const detectorsForJob = aggregatedData[jobId]; - - const detectorIndex = record.detector_index; - if (detectorsForJob[detectorIndex] === undefined) { - detectorsForJob[detectorIndex] = {}; - } - - // TODO - work out how best to display results from detectors with just an over field. - const firstFieldName = - record.partition_field_name || record.by_field_name || record.over_field_name; - const firstFieldValue = - record.partition_field_value || record.by_field_value || record.over_field_value; - if (firstFieldName !== undefined) { - const groupsForDetector = detectorsForJob[detectorIndex]; - - if (groupsForDetector[firstFieldName] === undefined) { - groupsForDetector[firstFieldName] = {}; - } - const valuesForGroup = groupsForDetector[firstFieldName]; - if (valuesForGroup[firstFieldValue] === undefined) { - valuesForGroup[firstFieldValue] = {}; - } - - const dataForGroupValue = valuesForGroup[firstFieldValue]; - - let isSecondSplit = false; - if (record.partition_field_name !== undefined) { - const splitFieldName = record.over_field_name || record.by_field_name; - if (splitFieldName !== undefined) { - isSecondSplit = true; - } - } - - if (isSecondSplit === false) { - if (dataForGroupValue.maxScoreRecord === undefined) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForGroupValue.maxScore) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } - } - } else { - // Aggregate another level for the over or by field. - const secondFieldName = record.over_field_name || record.by_field_name; - const secondFieldValue = record.over_field_value || record.by_field_value; - - if (dataForGroupValue[secondFieldName] === undefined) { - dataForGroupValue[secondFieldName] = {}; - } - - const splitsForGroup = dataForGroupValue[secondFieldName]; - if (splitsForGroup[secondFieldValue] === undefined) { - splitsForGroup[secondFieldValue] = {}; - } - - const dataForSplitValue = splitsForGroup[secondFieldValue]; - if (dataForSplitValue.maxScoreRecord === undefined) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForSplitValue.maxScore) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } - } - } - } else { - // Detector with no partition or by field. - const dataForDetector = detectorsForJob[detectorIndex]; - if (dataForDetector.maxScoreRecord === undefined) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } else { - if (record.record_score > dataForDetector.maxScore) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } - } - } - }); - - // Group job id by error message instead of by job: - const errorMessages = {}; - Object.keys(jobsErrorMessage).forEach((jobId) => { - const msg = jobsErrorMessage[jobId]; - if (errorMessages[msg] === undefined) { - errorMessages[msg] = new Set([jobId]); - } else { - errorMessages[msg].add(jobId); - } - }); - let recordsForSeries = []; - // Convert to an array of the records with the highest record_score per unique series. - each(aggregatedData, (detectorsForJob) => { - each(detectorsForJob, (groupsForDetector) => { - if (groupsForDetector.errorMessage !== undefined) { - recordsForSeries.push(groupsForDetector.errorMessage); - } else { - if (groupsForDetector.maxScoreRecord !== undefined) { - // Detector with no partition / by field. - recordsForSeries.push(groupsForDetector.maxScoreRecord); - } else { - each(groupsForDetector, (valuesForGroup) => { - each(valuesForGroup, (dataForGroupValue) => { - if (dataForGroupValue.maxScoreRecord !== undefined) { - recordsForSeries.push(dataForGroupValue.maxScoreRecord); - } else { - // Second level of aggregation for partition and by/over. - each(dataForGroupValue, (splitsForGroup) => { - each(splitsForGroup, (dataForSplitValue) => { - recordsForSeries.push(dataForSplitValue.maxScoreRecord); - }); - }); - } - }); - }); - } - } - }); - }); - recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse(); - - return [recordsForSeries, errorMessages]; -} - -function calculateChartRange( - seriesConfigs, - selectedEarliestMs, - selectedLatestMs, - chartWidth, - recordsToPlot, - timeFieldName -) { - let tooManyBuckets = false; - // Calculate the time range for the charts. - // Fit in as many points in the available container width plotted at the job bucket span. - // Look for the chart with the shortest bucket span as this determines - // the length of the time range that can be plotted. - const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2); - const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; - const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; - - const pointsToPlotFullSelection = Math.ceil( - (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs - ); - - // Optimally space points 5px apart. - const optimumPointSpacing = 5; - const optimumNumPoints = chartWidth / optimumPointSpacing; - - // Increase actual number of points if we can't plot the selected range - // at optimal point spacing. - const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); - const halfPoints = Math.ceil(plotPoints / 2); - const timefilter = getTimefilter(); - const bounds = timefilter.getActiveBounds(); - const boundsMin = bounds.min.valueOf(); - - let chartRange = { - min: Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin), - max: Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()), - }; - - if (plotPoints > CHART_MAX_POINTS) { - // For each series being plotted, display the record with the highest score if possible. - const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS; - let minMs = recordsToPlot[0][timeFieldName]; - let maxMs = recordsToPlot[0][timeFieldName]; - - each(recordsToPlot, (record) => { - const diffMs = maxMs - minMs; - if (diffMs < maxTimeSpan) { - const recordTime = record[timeFieldName]; - if (recordTime < minMs) { - if (maxMs - recordTime <= maxTimeSpan) { - minMs = recordTime; - } - } - - if (recordTime > maxMs) { - if (recordTime - minMs <= maxTimeSpan) { - maxMs = recordTime; - } - } - } - }); - - if (maxMs - minMs < maxTimeSpan) { - // Expand out before and after the span with the highest scoring anomalies, - // covering as much as the requested time span as possible. - // Work out if the high scoring region is nearer the start or end of the selected time span. - const diff = maxTimeSpan - (maxMs - minMs); - if (minMs - 0.5 * diff <= selectedEarliestMs) { - minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff); - maxMs = minMs + maxTimeSpan; - } else { - maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff); - minMs = maxMs - maxTimeSpan; - } - } - - chartRange = { min: minMs, max: maxMs }; - } - - // Elasticsearch aggregation returns points at start of bucket, - // so align the min to the length of the longest bucket. - chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; - if (chartRange.min < boundsMin) { - chartRange.min = chartRange.min + maxBucketSpanMs; - } - - if ( - (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && - chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs - ) { - tooManyBuckets = true; - } - - return { - chartRange, - tooManyBuckets, - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js deleted file mode 100644 index a2ad8efac67b4..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { cloneDeep } from 'lodash'; - -import mockAnomalyChartRecords from './__mocks__/mock_anomaly_chart_records.json'; -import mockDetectorsByJob from './__mocks__/mock_detectors_by_job.json'; -import mockJobConfig from './__mocks__/mock_job_config.json'; -import mockSeriesPromisesResponse from './__mocks__/mock_series_promises_response.json'; - -// Some notes on the tests and mocks: -// -// 'call anomalyChangeListener with actual series config' -// This test uses the standard mocks and uses the data as is provided via the mock files. -// The mocked services check for values in the data (e.g. 'mock-job-id', 'farequote-2017') -// and return the mock data from the files. -// -// 'filtering should skip values of null' -// This is is used to verify that values of `null` get filtered out but `0` is kept. -// The test clones mock data from files and adjusts job_id and indices to trigger -// suitable responses from the mocked services. The mocked services check against the -// provided alternative values and return specific modified mock responses for the test case. - -const mockJobConfigClone = cloneDeep(mockJobConfig); - -// adjust mock data to tests against null/0 values -const mockMetricClone = cloneDeep(mockSeriesPromisesResponse[0][0]); -mockMetricClone.results['1486712700000'] = null; -mockMetricClone.results['1486713600000'] = 0; - -jest.mock('../../services/job_service', () => ({ - mlJobService: { - getJob(jobId) { - // this is for 'call anomalyChangeListener with actual series config' - if (jobId === 'mock-job-id') { - return mockJobConfig; - } - // this is for 'filtering should skip values of null' - mockJobConfigClone.datafeed_config.indices = [`farequote-2017-${jobId}`]; - return mockJobConfigClone; - }, - detectorsByJob: mockDetectorsByJob, - }, -})); - -jest.mock('../../services/results_service', () => { - const { of } = require('rxjs'); - return { - mlResultsService: { - getMetricData(indices) { - // this is for 'call anomalyChangeListener with actual series config' - if (indices[0] === 'farequote-2017') { - return of(mockSeriesPromisesResponse[0][0]); - } - // this is for 'filtering should skip values of null' - return of(mockMetricClone); - }, - getRecordsForCriteria() { - return of(mockSeriesPromisesResponse[0][1]); - }, - getScheduledEventsByBucket() { - return of(mockSeriesPromisesResponse[0][2]); - }, - getEventDistributionData(indices) { - // this is for 'call anomalyChangeListener with actual series config' - if (indices[0] === 'farequote-2017') { - return Promise.resolve([]); - } - // this is for 'filtering should skip values of null' and - // resolves with a dummy object to trigger the processing - // of the event distribution chartdata filtering - return Promise.resolve([ - { - entity: 'mock', - }, - ]); - }, - }, - }; -}); - -jest.mock('../../util/string_utils', () => ({ - mlEscape(d) { - return d; - }, -})); - -jest.mock('../../util/dependency_cache', () => { - const dateMath = require('@elastic/datemath'); - let _time = undefined; - const timefilter = { - setTime: (time) => { - _time = time; - }, - getActiveBounds: () => { - return { - min: dateMath.parse(_time.from), - max: dateMath.parse(_time.to), - }; - }, - }; - return { - getTimefilter: () => timefilter, - }; -}); - -jest.mock('../explorer_dashboard_service', () => ({ - explorerService: { - setCharts: jest.fn(), - }, -})); - -import moment from 'moment'; -import { anomalyDataChange, getDefaultChartsData } from './explorer_charts_container_service'; -import { explorerService } from '../explorer_dashboard_service'; -import { getTimefilter } from '../../util/dependency_cache'; - -const timefilter = getTimefilter(); -timefilter.setTime({ - from: moment(1486425600000).toISOString(), // Feb 07 2017 - to: moment(1486857600000).toISOString(), // Feb 12 2017 -}); - -describe('explorerChartsContainerService', () => { - afterEach(() => { - explorerService.setCharts.mockClear(); - }); - - test('call anomalyChangeListener with empty series config', (done) => { - anomalyDataChange(1140, [], 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(1); - expect(explorerService.setCharts.mock.calls[0][0]).toStrictEqual({ - ...getDefaultChartsData(), - chartsPerRow: 2, - }); - done(); - }); - }); - - test('call anomalyChangeListener with actual series config', (done) => { - anomalyDataChange(1140, mockAnomalyChartRecords, 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - expect(explorerService.setCharts.mock.calls[0][0]).toMatchSnapshot(); - expect(explorerService.setCharts.mock.calls[1][0]).toMatchSnapshot(); - done(); - }); - }); - - test('filtering should skip values of null', (done) => { - const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords).map((d) => { - d.job_id = 'mock-job-id-distribution'; - return d; - }); - - anomalyDataChange(1140, mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - expect(explorerService.setCharts.mock.calls[0][0].seriesToPlot.length).toBe(1); - expect(explorerService.setCharts.mock.calls[1][0].seriesToPlot.length).toBe(1); - - // the mock source dataset has a length of 115. one data point has a value of `null`, - // and another one `0`. the received dataset should have a length of 114, - // it should remove the datapoint with `null` and keep the one with `0`. - const chartData = explorerService.setCharts.mock.calls[1][0].seriesToPlot[0].chartData; - expect(chartData).toHaveLength(114); - expect(chartData.filter((d) => d.value === 0)).toHaveLength(1); - expect(chartData.filter((d) => d.value === null)).toHaveLength(0); - done(); - }); - }); - - test('field value with trailing dot should not throw an error', (done) => { - const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords); - mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; - - expect(() => { - anomalyDataChange(1140, mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - }).not.toThrow(); - - setImmediate(() => { - expect(explorerService.setCharts.mock.calls.length).toBe(2); - done(); - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts new file mode 100644 index 0000000000000..aa2eabbd4a38e --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Service for the container for the anomaly charts in the + * Machine Learning Explorer dashboard. + * The service processes the data required to draw each of the charts + * and manages the layout of the charts in the containing div. + */ + +import type { JobId } from '../../../../common/types/anomaly_detection_jobs'; +import { SeriesConfigWithMetadata } from '../../services/anomaly_explorer_charts_service'; + +export interface ExplorerChartSeriesErrorMessages { + [key: string]: Set; +} +export declare interface ExplorerChartsData { + chartsPerRow: number; + seriesToPlot: SeriesConfigWithMetadata[]; + tooManyBuckets: boolean; + timeFieldName: string; + errorMessages: ExplorerChartSeriesErrorMessages | undefined; +} + +export function getDefaultChartsData(): ExplorerChartsData { + return { + chartsPerRow: 1, + errorMessages: undefined, + seriesToPlot: [], + // default values, will update on every re-render + tooManyBuckets: false, + timeFieldName: 'timestamp', + }; +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 4ad0041df73e4..125ccf38b784d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -50,7 +50,9 @@ export const CHART_TYPE = { POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', GEO_MAP: 'geo_map', -}; +} as const; + +export type ChartType = typeof CHART_TYPE[keyof typeof CHART_TYPE]; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index cf632ce41ae3f..49707bc927361 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -156,3 +156,5 @@ export const explorerService = { explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload }); }, }; + +export type ExplorerService = typeof explorerService; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 855106801cbb1..9e24a4349584e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -5,11 +5,13 @@ * 2.0. */ -import { Moment } from 'moment'; - import { AnnotationsTable } from '../../../common/types/annotations'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { SwimlaneType } from './explorer_constants'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { RecordForInfluencer } from '../services/results_service/results_service'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { MlResultsService } from '../services/results_service'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -31,6 +33,10 @@ export declare interface SwimlaneData { interval: number; } +interface ChartRecord extends RecordForInfluencer { + function: string; +} + export declare interface OverallSwimlaneData extends SwimlaneData { earliest: number; latest: number; @@ -98,11 +104,6 @@ export declare interface ExplorerJob { export declare const createJobs: (jobs: CombinedJob[]) => ExplorerJob[]; -export declare interface TimeRangeBounds { - min: Moment | undefined; - max: Moment | undefined; -} - declare interface SwimlaneBounds { earliest: number; latest: number; @@ -132,17 +133,20 @@ export declare const loadAnomaliesTableData: ( fieldName: string, tableInterval: string, tableSeverity: number, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ) => Promise; export declare const loadDataForCharts: ( + mlResultsService: MlResultsService, jobIds: string[], earliestMs: number, latestMs: number, influencers: any[], selectedCells: AppStateSelectedCells | undefined, - influencersFilterQuery: any -) => Promise; + influencersFilterQuery: InfluencersFilterQuery, + // choose whether or not to keep track of the request that could be out of date + takeLatestOnly: boolean +) => Promise; export declare const loadFilteredTopInfluencers: ( jobIds: string[], @@ -151,10 +155,11 @@ export declare const loadFilteredTopInfluencers: ( records: any[], influencers: any[], noInfluencersConfigured: boolean, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ) => Promise; export declare const loadTopInfluencers: ( + mlResultsService: MlResultsService, selectedJobIds: string[], earliestMs: number, latestMs: number, @@ -178,7 +183,7 @@ export declare const loadViewByTopFieldValuesForSelectedTime: ( ) => Promise; export declare interface FilterData { - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; filterActive: boolean; filteredFields: string[]; queryString: string; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index 2f19cbc80f055..ea101d104f783 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -26,7 +26,6 @@ import { import { parseInterval } from '../../../common/util/parse_interval'; import { ml } from '../services/ml_api_service'; import { mlJobService } from '../services/job_service'; -import { mlResultsService } from '../services/results_service'; import { getTimeBucketsFromCache } from '../util/time_buckets'; import { getTimefilter, getUiSettings } from '../util/dependency_cache'; @@ -65,6 +64,7 @@ export function getDefaultSwimlaneData() { } export async function loadFilteredTopInfluencers( + mlResultsService, jobIds, earliestMs, latestMs, @@ -125,6 +125,7 @@ export async function loadFilteredTopInfluencers( }); return await loadTopInfluencers( + mlResultsService, jobIds, earliestMs, latestMs, @@ -539,12 +540,17 @@ export async function loadAnomaliesTableData( // and avoid race conditions ending up with the wrong charts. let requestCount = 0; export async function loadDataForCharts( + mlResultsService, jobIds, earliestMs, latestMs, influencers = [], selectedCells, - influencersFilterQuery + influencersFilterQuery, + // choose whether or not to keep track of the request that could be out of date + // in Anomaly Explorer this is being used to ignore any request that are out of date + // but in embeddables, we might have multiple requests coming from multiple different panels + takeLatestOnly = true ) { return new Promise((resolve) => { // Just skip doing the request when this function @@ -573,7 +579,7 @@ export async function loadDataForCharts( ) .then((resp) => { // Ignore this response if it's returned by an out of date promise - if (newRequestCount < requestCount) { + if (takeLatestOnly && newRequestCount < requestCount) { resolve([]); } @@ -590,6 +596,7 @@ export async function loadDataForCharts( } export async function loadTopInfluencers( + mlResultsService, selectedJobIds, earliestMs, latestMs, diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 5d168c7827525..bb90fedfc2315 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -24,6 +24,7 @@ import { } from '../../explorer_utils'; import { AnnotationsTable } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; +import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; export interface ExplorerState { annotations: AnnotationsTable; @@ -33,7 +34,7 @@ export interface ExplorerState { filteredFields: any[]; filterPlaceHolder: any; indexPattern: { title: string; fields: any[] }; - influencersFilterQuery: any; + influencersFilterQuery: InfluencersFilterQuery; influencers: Dictionary; isAndOperator: boolean; loading: boolean; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index c108257094b6a..4adb79f065cd4 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -361,7 +361,7 @@ export const SwimlaneContainer: FC = ({ diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index e65ca22effd76..b651b311f13aa 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -37,6 +37,7 @@ import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { useExplorerUrlState } from '../../explorer/hooks/use_explorer_url_state'; +import { useTimeBuckets } from '../../components/custom_hooks/use_time_buckets'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -84,6 +85,8 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); + + const timeBuckets = useTimeBuckets(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -265,6 +268,8 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim stoppedPartitions, invalidTimeRangeError, selectedJobsRunning, + timeBuckets, + timefilter, }} />
diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index b6ad6b015a085..c06094b44f4a0 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -17,7 +17,7 @@ import { NavigateToPath, useNotifications } from '../../contexts/kibana'; import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; import { TimeSeriesExplorer } from '../../timeseriesexplorer'; -import { getDateFormatTz, TimeRangeBounds } from '../../explorer/explorer_utils'; +import { getDateFormatTz } from '../../explorer/explorer_utils'; import { ml } from '../../services/ml_api_service'; import { mlJobService } from '../../services/job_service'; import { mlForecastService } from '../../services/forecast_service'; @@ -43,7 +43,8 @@ import { useToastNotificationService } from '../../services/toast_notification_s import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { useTimeSeriesExplorerUrlState } from '../../timeseriesexplorer/hooks/use_timeseriesexplorer_url_state'; -import { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import type { TimeSeriesExplorerAppState } from '../../../../common/types/ml_url_generator'; +import type { TimeRangeBounds } from '../../util/time_buckets'; export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts new file mode 100644 index 0000000000000..e36f8985f8ffe --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_detector_service.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyDetectorServiceMock = () => ({ + getJobs$: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts new file mode 100644 index 0000000000000..21f07ed9e5a3c --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/anomaly_explorer_charts_service.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const createAnomalyExplorerChartsServiceMock = () => ({ + getCombinedJobs: jest.fn(), + getAnomalyData: jest.fn(), + setTimeRange: jest.fn(), + getTimeBounds: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts new file mode 100644 index 0000000000000..b63ae2f859b65 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/__mocks__/ml_api_services.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const mlApiServicesMock = { + jobs: { + jobForCloning: jest.fn(), + }, +}; diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts new file mode 100644 index 0000000000000..36e18b49cfa84 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { AnomalyExplorerChartsService } from './anomaly_explorer_charts_service'; +import mockAnomalyChartRecords from '../explorer/explorer_charts/__mocks__/mock_anomaly_chart_records.json'; +import mockJobConfig from '../explorer/explorer_charts/__mocks__/mock_job_config.json'; +import mockSeriesPromisesResponse from '../explorer/explorer_charts/__mocks__/mock_series_promises_response.json'; +import { of } from 'rxjs'; +import { cloneDeep } from 'lodash'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import type { ExplorerChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; +import type { ExplorerService } from '../explorer/explorer_dashboard_service'; +import type { MlApiServices } from './ml_api_service'; +import type { MlResultsService } from './results_service'; +import { getDefaultChartsData } from '../explorer/explorer_charts/explorer_charts_container_service'; +import { timefilterMock } from '../contexts/kibana/__mocks__/use_timefilter'; +import { mlApiServicesMock } from './__mocks__/ml_api_services'; + +// Some notes on the tests and mocks: +// +// 'call anomalyChangeListener with actual series config' +// This test uses the standard mocks and uses the data as is provided via the mock files. +// The mocked services check for values in the data (e.g. 'mock-job-id', 'farequote-2017') +// and return the mock data from the files. +// +// 'filtering should skip values of null' +// This is is used to verify that values of `null` get filtered out but `0` is kept. +// The test clones mock data from files and adjusts job_id and indices to trigger +// suitable responses from the mocked services. The mocked services check against the +// provided alternative values and return specific modified mock responses for the test case. + +const mockJobConfigClone = cloneDeep(mockJobConfig); + +// adjust mock data to tests against null/0 values +const mockMetricClone = cloneDeep(mockSeriesPromisesResponse[0][0]); +// @ts-ignore +mockMetricClone.results['1486712700000'] = null; +// @ts-ignore +mockMetricClone.results['1486713600000'] = 0; + +export const mlResultsServiceMock = { + getMetricData: jest.fn((indices) => { + // this is for 'call anomalyChangeListener with actual series config' + if (indices[0] === 'farequote-2017') { + return of(mockSeriesPromisesResponse[0][0]); + } + // this is for 'filtering should skip values of null' + return of(mockMetricClone); + }), + getRecordsForCriteria: jest.fn(() => { + return of(mockSeriesPromisesResponse[0][1]); + }), + getScheduledEventsByBucket: jest.fn(() => of(mockSeriesPromisesResponse[0][2])), + getEventDistributionData: jest.fn((indices) => { + // this is for 'call anomalyChangeListener with actual series config' + if (indices[0] === 'farequote-2017') { + return Promise.resolve([]); + } + // this is for 'filtering should skip values of null' and + // resolves with a dummy object to trigger the processing + // of the event distribution chartdata filtering + return Promise.resolve([ + { + entity: 'mock', + }, + ]); + }), +}; + +const assertAnomalyDataResult = (anomalyData: ExplorerChartsData) => { + expect(anomalyData.chartsPerRow).toBe(1); + expect(Array.isArray(anomalyData.seriesToPlot)).toBe(true); + expect(anomalyData.seriesToPlot.length).toBe(1); + expect(anomalyData.errorMessages).toMatchObject({}); + expect(anomalyData.tooManyBuckets).toBe(false); + expect(anomalyData.timeFieldName).toBe('timestamp'); +}; +describe('AnomalyExplorerChartsService', () => { + const jobId = 'mock-job-id'; + const combinedJobRecords = { + [jobId]: mockJobConfigClone, + }; + const anomalyExplorerService = new AnomalyExplorerChartsService( + timefilterMock, + (mlApiServicesMock as unknown) as MlApiServices, + (mlResultsServiceMock as unknown) as MlResultsService + ); + const explorerService = { + setCharts: jest.fn(), + }; + + const timeRange = { + earliestMs: 1486656000000, + latestMs: 1486670399999, + }; + + beforeEach(() => { + mlApiServicesMock.jobs.jobForCloning.mockImplementation(() => + Promise.resolve({ job: mockJobConfigClone, datafeed: mockJobConfigClone.datafeed_config }) + ); + }); + + afterEach(() => { + explorerService.setCharts.mockClear(); + }); + + test('should return anomaly data without explorer service', async () => { + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + assertAnomalyDataResult(anomalyData); + }); + + test('should set anomaly data with explorer service side effects', async () => { + await anomalyExplorerService.getAnomalyData( + (explorerService as unknown) as ExplorerService, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + ); + + expect(explorerService.setCharts.mock.calls.length).toBe(2); + assertAnomalyDataResult(explorerService.setCharts.mock.calls[0][0]); + assertAnomalyDataResult(explorerService.setCharts.mock.calls[1][0]); + }); + + test('call anomalyChangeListener with empty series config', async () => { + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + // @ts-ignore + (combinedJobRecords as unknown) as Record, + 1000, + [], + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + expect(anomalyData).toStrictEqual({ + ...getDefaultChartsData(), + chartsPerRow: 2, + }); + }); + + test('field value with trailing dot should not throw an error', async () => { + const mockAnomalyChartRecordsClone = cloneDeep(mockAnomalyChartRecords); + mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; + + const anomalyData = (await anomalyExplorerService.getAnomalyData( + undefined, + (combinedJobRecords as unknown) as Record, + 1000, + mockAnomalyChartRecordsClone, + timeRange.earliestMs, + timeRange.latestMs, + timefilterMock, + 0, + 12 + )) as ExplorerChartsData; + expect(anomalyData).toBeDefined(); + expect(anomalyData!.chartsPerRow).toBe(2); + expect(Array.isArray(anomalyData!.seriesToPlot)).toBe(true); + expect(anomalyData!.seriesToPlot.length).toBe(2); + }); +}); diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts new file mode 100644 index 0000000000000..59b6860cb65b7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -0,0 +1,1056 @@ +/* + * 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 { each, find, get, map, reduce, sortBy } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { RecordForInfluencer } from './results_service/results_service'; +import { + isMappableJob, + isModelPlotChartableForDetector, + isModelPlotEnabled, + isSourceDataChartableForDetector, + mlFunctionToESAggregation, +} from '../../../common/util/job_utils'; +import { EntityField, getEntityFieldList } from '../../../common/util/anomaly_utils'; +import { CombinedJob, Datafeed, JobId } from '../../../common/types/anomaly_detection_jobs'; +import { MlApiServices } from './ml_api_service'; +import { SWIM_LANE_LABEL_WIDTH } from '../explorer/swimlane_container'; +import { ES_AGGREGATION, ML_JOB_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { _DOC_COUNT, DOC_COUNT } from '../../../common/constants/field_types'; +import { getChartType, chartLimits } from '../util/chart_utils'; +import { CriteriaField, MlResultsService } from './results_service'; +import { TimefilterContract, TimeRange } from '../../../../../../src/plugins/data/public'; +import { CHART_TYPE, ChartType } from '../explorer/explorer_constants'; +import type { ChartRecord } from '../explorer/explorer_utils'; +import { RecordsForCriteria, ScheduledEventsByBucket } from './results_service/result_service_rx'; +import { isPopulatedObject } from '../../../common/util/object_utils'; +import type { ExplorerService } from '../explorer/explorer_dashboard_service'; +import { AnomalyRecordDoc } from '../../../common/types/anomalies'; +import { + ExplorerChartsData, + getDefaultChartsData, +} from '../explorer/explorer_charts/explorer_charts_container_service'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { isDefined } from '../../../common/types/guards'; +const CHART_MAX_POINTS = 500; +const ANOMALIES_MAX_RESULTS = 500; +const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. +const ML_TIME_FIELD_NAME = 'timestamp'; +const USE_OVERALL_CHART_LIMITS = false; +const MAX_CHARTS_PER_ROW = 4; + +interface ChartPoint { + date: number; + anomalyScore?: number; + actual?: number[]; + multiBucketImpact?: number; + typical?: number[]; + value?: number | null; + entity?: string; + byFieldName?: string; + numberOfCauses?: number; + scheduledEvents?: any[]; +} +interface MetricData { + results: Record; + success: boolean; +} +interface SeriesConfig { + jobId: JobId; + detectorIndex: number; + metricFunction: ML_JOB_AGGREGATION.LAT_LONG | ES_AGGREGATION | null; + timeField: string; + interval: string; + datafeedConfig: Datafeed; + summaryCountFieldName?: string; + metricFieldName?: string; +} + +interface InfoTooltip { + jobId: JobId; + aggregationInterval?: string; + chartFunction: string; + entityFields: EntityField[]; +} +export interface SeriesConfigWithMetadata extends SeriesConfig { + functionDescription?: string; + bucketSpanSeconds: number; + detectorLabel?: string; + fieldName: string; + entityFields: EntityField[]; + infoTooltip?: InfoTooltip; + loading?: boolean; + chartData?: ChartPoint[] | null; + mapData?: Array; +} + +export const isSeriesConfigWithMetadata = (arg: unknown): arg is SeriesConfigWithMetadata => { + return ( + isPopulatedObject(arg) && + {}.hasOwnProperty.call(arg, 'bucketSpanSeconds') && + {}.hasOwnProperty.call(arg, 'detectorLabel') + ); +}; + +interface ChartRange { + min: number; + max: number; +} + +export const DEFAULT_MAX_SERIES_TO_PLOT = 6; + +/** + * Service for retrieving anomaly explorer charts data. + */ +export class AnomalyExplorerChartsService { + private _customTimeRange: TimeRange | undefined; + + constructor( + private timeFilter: TimefilterContract, + private mlApiServices: MlApiServices, + private mlResultsService: MlResultsService + ) { + this.timeFilter.enableTimeRangeSelector(); + } + + public setTimeRange(timeRange: TimeRange) { + this._customTimeRange = timeRange; + } + + public getTimeBounds(): TimeRangeBounds { + return this._customTimeRange !== undefined + ? this.timeFilter.calculateBounds(this._customTimeRange) + : this.timeFilter.getBounds(); + } + + public calculateChartRange( + seriesConfigs: SeriesConfigWithMetadata[], + selectedEarliestMs: number, + selectedLatestMs: number, + chartWidth: number, + recordsToPlot: ChartRecord[], + timeFieldName: string, + timeFilter: TimefilterContract + ) { + let tooManyBuckets = false; + // Calculate the time range for the charts. + // Fit in as many points in the available container width plotted at the job bucket span. + // Look for the chart with the shortest bucket span as this determines + // the length of the time range that can be plotted. + const midpointMs = Math.ceil((selectedEarliestMs + selectedLatestMs) / 2); + const minBucketSpanMs = Math.min.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; + const maxBucketSpanMs = Math.max.apply(null, map(seriesConfigs, 'bucketSpanSeconds')) * 1000; + + const pointsToPlotFullSelection = Math.ceil( + (selectedLatestMs - selectedEarliestMs) / minBucketSpanMs + ); + + // Optimally space points 5px apart. + const optimumPointSpacing = 5; + const optimumNumPoints = chartWidth / optimumPointSpacing; + + // Increase actual number of points if we can't plot the selected range + // at optimal point spacing. + const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); + const halfPoints = Math.ceil(plotPoints / 2); + const bounds = timeFilter.getActiveBounds(); + const boundsMin = bounds?.min ? bounds.min.valueOf() : undefined; + let chartRange: ChartRange = { + min: boundsMin + ? Math.max(midpointMs - halfPoints * minBucketSpanMs, boundsMin) + : midpointMs - halfPoints * minBucketSpanMs, + max: bounds?.max + ? Math.min(midpointMs + halfPoints * minBucketSpanMs, bounds.max.valueOf()) + : midpointMs + halfPoints * minBucketSpanMs, + }; + + if (plotPoints > CHART_MAX_POINTS) { + // For each series being plotted, display the record with the highest score if possible. + const maxTimeSpan = minBucketSpanMs * CHART_MAX_POINTS; + let minMs = recordsToPlot[0][timeFieldName]; + let maxMs = recordsToPlot[0][timeFieldName]; + + each(recordsToPlot, (record) => { + const diffMs = maxMs - minMs; + if (diffMs < maxTimeSpan) { + const recordTime = record[timeFieldName]; + if (recordTime < minMs) { + if (maxMs - recordTime <= maxTimeSpan) { + minMs = recordTime; + } + } + + if (recordTime > maxMs) { + if (recordTime - minMs <= maxTimeSpan) { + maxMs = recordTime; + } + } + } + }); + + if (maxMs - minMs < maxTimeSpan) { + // Expand out before and after the span with the highest scoring anomalies, + // covering as much as the requested time span as possible. + // Work out if the high scoring region is nearer the start or end of the selected time span. + const diff = maxTimeSpan - (maxMs - minMs); + if (minMs - 0.5 * diff <= selectedEarliestMs) { + minMs = Math.max(selectedEarliestMs, minMs - 0.5 * diff); + maxMs = minMs + maxTimeSpan; + } else { + maxMs = Math.min(selectedLatestMs, maxMs + 0.5 * diff); + minMs = maxMs - maxTimeSpan; + } + } + + chartRange = { min: minMs, max: maxMs }; + } + + // Elasticsearch aggregation returns points at start of bucket, + // so align the min to the length of the longest bucket. + chartRange.min = Math.floor(chartRange.min / maxBucketSpanMs) * maxBucketSpanMs; + if (boundsMin !== undefined && chartRange.min < boundsMin) { + chartRange.min = chartRange.min + maxBucketSpanMs; + } + + if ( + (chartRange.min > selectedEarliestMs || chartRange.max < selectedLatestMs) && + chartRange.max - chartRange.min < selectedLatestMs - selectedEarliestMs + ) { + tooManyBuckets = true; + } + + return { + chartRange, + tooManyBuckets, + }; + } + + public buildConfigFromDetector(job: CombinedJob, detectorIndex: number) { + const analysisConfig = job.analysis_config; + const detector = analysisConfig.detectors[detectorIndex]; + + const config: SeriesConfig = { + jobId: job.job_id, + detectorIndex, + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), + timeField: job.data_description.time_field, + interval: job.analysis_config.bucket_span, + datafeedConfig: job.datafeed_config, + summaryCountFieldName: job.analysis_config.summary_count_field_name, + metricFieldName: undefined, + }; + + if (detector.field_name !== undefined) { + config.metricFieldName = detector.field_name; + } + + // Extra checks if the job config uses a summary count field. + const summaryCountFieldName = analysisConfig.summary_count_field_name; + if ( + config.metricFunction === ES_AGGREGATION.COUNT && + summaryCountFieldName !== undefined && + summaryCountFieldName !== DOC_COUNT && + summaryCountFieldName !== _DOC_COUNT + ) { + // Check for a detector looking at cardinality (distinct count) using an aggregation. + // The cardinality field will be in: + // aggregations//aggregations//cardinality/field + // or aggs//aggs//cardinality/field + let cardinalityField; + const topAgg = get(job.datafeed_config, 'aggregations') || get(job.datafeed_config, 'aggs'); + if (topAgg !== undefined && Object.values(topAgg).length > 0) { + cardinalityField = + get(Object.values(topAgg)[0], [ + 'aggregations', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]) || + get(Object.values(topAgg)[0], [ + 'aggs', + summaryCountFieldName, + ES_AGGREGATION.CARDINALITY, + 'field', + ]); + } + if ( + (detector.function === ML_JOB_AGGREGATION.NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT || + detector.function === ML_JOB_AGGREGATION.COUNT || + detector.function === ML_JOB_AGGREGATION.HIGH_COUNT || + detector.function === ML_JOB_AGGREGATION.LOW_COUNT) && + cardinalityField !== undefined + ) { + config.metricFunction = ES_AGGREGATION.CARDINALITY; + config.metricFieldName = undefined; + } else { + // For count detectors using summary_count_field, plot sum(summary_count_field_name) + config.metricFunction = ES_AGGREGATION.SUM; + config.metricFieldName = summaryCountFieldName; + } + } + + return config; + } + + public buildConfig(record: ChartRecord, job: CombinedJob): SeriesConfigWithMetadata { + const detectorIndex = record.detector_index; + const config: Omit< + SeriesConfigWithMetadata, + 'bucketSpanSeconds' | 'detectorLabel' | 'fieldName' | 'entityFields' | 'infoTooltip' + > = { + ...this.buildConfigFromDetector(job, detectorIndex), + }; + + const fullSeriesConfig: SeriesConfigWithMetadata = { + bucketSpanSeconds: 0, + entityFields: [], + fieldName: '', + ...config, + }; + // Add extra properties used by the explorer dashboard charts. + fullSeriesConfig.functionDescription = record.function_description; + + const parsedBucketSpan = parseInterval(job.analysis_config.bucket_span); + if (parsedBucketSpan !== null) { + fullSeriesConfig.bucketSpanSeconds = parsedBucketSpan.asSeconds(); + } + + fullSeriesConfig.detectorLabel = record.function; + const jobDetectors = job.analysis_config.detectors; + if (jobDetectors) { + fullSeriesConfig.detectorLabel = jobDetectors[detectorIndex].detector_description; + } else { + if (record.field_name !== undefined) { + fullSeriesConfig.detectorLabel += ` ${fullSeriesConfig.fieldName}`; + } + } + + if (record.field_name !== undefined) { + fullSeriesConfig.fieldName = record.field_name; + fullSeriesConfig.metricFieldName = record.field_name; + } + + // Add the 'entity_fields' i.e. the partition, by, over fields which + // define the metric series to be plotted. + fullSeriesConfig.entityFields = getEntityFieldList(record); + + if (record.function === ML_JOB_AGGREGATION.METRIC) { + fullSeriesConfig.metricFunction = mlFunctionToESAggregation(record.function_description); + } + + // Build the tooltip data for the chart info icon, showing further details on what is being plotted. + let functionLabel = `${config.metricFunction}`; + if ( + fullSeriesConfig.metricFieldName !== undefined && + fullSeriesConfig.metricFieldName !== null + ) { + functionLabel += ` ${fullSeriesConfig.metricFieldName}`; + } + + fullSeriesConfig.infoTooltip = { + jobId: record.job_id, + aggregationInterval: fullSeriesConfig.interval, + chartFunction: functionLabel, + entityFields: fullSeriesConfig.entityFields.map((f) => ({ + fieldName: f.fieldName, + fieldValue: f.fieldValue, + })), + }; + + return fullSeriesConfig; + } + public async getCombinedJobs(jobIds: string[]): Promise { + const combinedResults = await Promise.all( + // Getting only necessary job config and datafeed config without the stats + jobIds.map((jobId) => this.mlApiServices.jobs.jobForCloning(jobId)) + ); + const combinedJobs = combinedResults + .filter(isDefined) + .filter((r) => r.job !== undefined && r.datafeed !== undefined) + .map(({ job, datafeed }) => ({ ...job, datafeed_config: datafeed } as CombinedJob)); + return combinedJobs; + } + + public async getAnomalyData( + explorerService: ExplorerService | undefined, + combinedJobRecords: Record, + chartsContainerWidth: number, + anomalyRecords: ChartRecord[] | undefined, + selectedEarliestMs: number, + selectedLatestMs: number, + timefilter: TimefilterContract, + severity = 0, + maxSeries = DEFAULT_MAX_SERIES_TO_PLOT + ): Promise { + const data = getDefaultChartsData(); + + const containerWith = chartsContainerWidth + SWIM_LANE_LABEL_WIDTH; + if (anomalyRecords === undefined) return; + const filteredRecords = anomalyRecords.filter((record) => { + return Number(record.record_score) >= severity; + }); + const { records: allSeriesRecords, errors: errorMessages } = this.processRecordsForDisplay( + combinedJobRecords, + filteredRecords + ); + + if (!Array.isArray(allSeriesRecords)) return; + // Calculate the number of charts per row, depending on the width available, to a max of 4. + let chartsPerRow = Math.min(Math.max(Math.floor(containerWith / 550), 1), MAX_CHARTS_PER_ROW); + + // Expand the chart to full size if there's only one viewable chart + if (allSeriesRecords.length === 1 || maxSeries === 1) { + chartsPerRow = 1; + } + + // Expand the charts to not have blank space in the row if necessary + if (maxSeries < chartsPerRow) { + chartsPerRow = maxSeries; + } + + data.chartsPerRow = chartsPerRow; + + // Build the data configs of the anomalies to be displayed. + // TODO - implement paging? + // For now just take first 6 (or 8 if 4 charts per row). + const maxSeriesToPlot = maxSeries ?? Math.max(chartsPerRow * 2, 6); + const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const hasGeoData = recordsToPlot.find( + (record) => (record.function_description || record.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map((record) => + this.buildConfig(record, combinedJobRecords[record.job_id]) + ); + const seriesConfigsNoGeoData = []; + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData: SeriesConfigWithMetadata[] = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if ( + config.detectorLabel !== undefined && + config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG) + ) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } + + // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. + data.tooManyBuckets = false; + const chartWidth = Math.floor(containerWith / chartsPerRow); + const { chartRange, tooManyBuckets } = this.calculateChartRange( + seriesConfigs as SeriesConfigWithMetadata[], + selectedEarliestMs, + selectedLatestMs, + chartWidth, + recordsToPlot, + data.timeFieldName, + timefilter + ); + data.tooManyBuckets = tooManyBuckets; + + if (errorMessages) { + data.errorMessages = errorMessages; + } + + if (explorerService) { + explorerService.setCharts({ ...data }); + } + if (seriesConfigs.length === 0) { + return data; + } + + // Query 1 - load the raw metric data. + function getMetricData( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ): Promise { + const { jobId, detectorIndex, entityFields, bucketSpanSeconds } = config; + + const job = combinedJobRecords[jobId]; + + // If the job uses aggregation or scripted fields, and if it's a config we don't support + // use model plot data if model plot is enabled + // else if source data can be plotted, use that, otherwise model plot will be available. + const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); + if (useSourceData === true) { + const datafeedQuery = get(config, 'datafeedConfig.query', null); + return mlResultsService + .getMetricData( + Array.isArray(config.datafeedConfig.indices) + ? config.datafeedConfig.indices[0] + : config.datafeedConfig.indices, + entityFields, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.summaryCountFieldName, + config.timeField, + range.min, + range.max, + bucketSpanSeconds * 1000, + config.datafeedConfig + ) + .toPromise(); + } else { + // Extract the partition, by, over fields on which to filter. + const criteriaFields: CriteriaField[] = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (detector.partition_field_name !== undefined) { + const partitionEntity = find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); + } + } + + if (detector.over_field_name !== undefined) { + const overEntity = find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); + } + } + + if (detector.by_field_name !== undefined) { + const byEntity = find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); + } + } + + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {} as Record, + }; + + return mlResultsService + .getModelPlotOutput( + jobId, + detectorIndex, + criteriaFields, + range.min, + range.max, + bucketSpanSeconds * 1000 + ) + .toPromise() + .then((resp) => { + // Return data in format required by the explorer charts. + const results = resp.results; + Object.keys(results).forEach((time) => { + obj.results[time] = results[time].actual; + }); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + } + } + + // Query 2 - load the anomalies. + // Criteria to return the records for this series are the detector_index plus + // the specific combination of 'entity' fields i.e. the partition / by / over fields. + function getRecordsForCriteria( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + let criteria: EntityField[] = []; + criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); + criteria = criteria.concat(config.entityFields); + return mlResultsService + .getRecordsForCriteria( + [config.jobId], + criteria, + 0, + range.min, + range.max, + ANOMALIES_MAX_RESULTS + ) + .toPromise(); + } + + // Query 3 - load any scheduled events for the job. + function getScheduledEvents( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + return mlResultsService + .getScheduledEventsByBucket( + [config.jobId], + range.min, + range.max, + config.bucketSpanSeconds * 1000, + 1, + MAX_SCHEDULED_EVENTS + ) + .toPromise(); + } + + // Query 4 - load context data distribution + function getEventDistribution( + mlResultsService: MlResultsService, + config: SeriesConfigWithMetadata, + range: ChartRange + ) { + const chartType = getChartType(config); + + let splitField; + let filterField = null; + + // Define splitField and filterField based on chartType + if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { + splitField = config.entityFields.find((f) => f.fieldType === 'by'); + filterField = config.entityFields.find((f) => f.fieldType === 'partition'); + } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { + splitField = config.entityFields.find((f) => f.fieldType === 'over'); + filterField = config.entityFields.find((f) => f.fieldType === 'partition'); + } + + const datafeedQuery = get(config, 'datafeedConfig.query', null); + return mlResultsService.getEventDistributionData( + Array.isArray(config.datafeedConfig.indices) + ? config.datafeedConfig.indices[0] + : config.datafeedConfig.indices, + splitField, + filterField, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.bucketSpanSeconds * 1000 + ); + } + + // first load and wait for required data, + // only after that trigger data processing and page render. + // TODO - if query returns no results e.g. source data has been deleted, + // display a message saying 'No data between earliest/latest'. + const seriesPromises: Array< + Promise<[MetricData, RecordsForCriteria, ScheduledEventsByBucket, any]> + > = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesConfigsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesConfigsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(this.mlResultsService, seriesConfig, chartRange), + getRecordsForCriteria(this.mlResultsService, seriesConfig, chartRange), + getScheduledEvents(this.mlResultsService, seriesConfig, chartRange), + getEventDistribution(this.mlResultsService, seriesConfig, chartRange), + ]) + ); + }); + function processChartData( + response: [MetricData, RecordsForCriteria, ScheduledEventsByBucket, any], + seriesIndex: number + ) { + const metricData = response[0].results; + const records = response[1].records; + const jobId = seriesConfigsForPromises[seriesIndex].jobId; + const scheduledEvents = response[2].events[jobId]; + const eventDistribution = response[3]; + const chartType = getChartType(seriesConfigsForPromises[seriesIndex]); + + // Sort records in ascending time order matching up with chart data + records.sort((recordA, recordB) => { + return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; + }); + + // Return dataset in format used by the chart. + // i.e. array of Objects with keys date (timestamp), value, + // plus anomalyScore for points with anomaly markers. + let chartData: ChartPoint[] = []; + if (metricData !== undefined) { + if (eventDistribution.length > 0 && records.length > 0) { + const filterField = records[0].by_field_value || records[0].over_field_value; + chartData = eventDistribution.filter((d: { entity: any }) => d.entity !== filterField); + map(metricData, (value, time) => { + // The filtering for rare/event_distribution charts needs to be handled + // differently because of how the source data is structured. + // For rare chart values we are only interested wether a value is either `0` or not, + // `0` acts like a flag in the chart whether to display the dot/marker. + // All other charts (single metric, population) are metric based and with + // those a value of `null` acts as the flag to hide a data point. + if ( + (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || + (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) + ) { + chartData.push({ + date: +time, + value, + entity: filterField, + }); + } + }); + } else { + chartData = map(metricData, (value, time) => ({ + date: +time, + value, + })); + } + } + + // Iterate through the anomaly records, adding anomalyScore properties + // to the chartData entries for anomalous buckets. + const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); + each(records, (record) => { + // Look for a chart point with the same time as the record. + // If none found, insert a point for anomalies due to a gap in the data. + const recordTime = record[ML_TIME_FIELD_NAME]; + let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); + if (chartPoint === undefined) { + chartPoint = { date: recordTime, value: null }; + chartData.push(chartPoint); + } + if (chartPoint !== undefined) { + chartPoint.anomalyScore = record.record_score; + + if (record.actual !== undefined) { + chartPoint.actual = record.actual; + chartPoint.typical = record.typical; + } else { + const causes = get(record, 'causes', []); + if (causes.length > 0) { + chartPoint.byFieldName = record.by_field_name; + chartPoint.numberOfCauses = causes.length; + if (causes.length === 1) { + // If only a single cause, copy actual and typical values to the top level. + const cause = record.causes[0]; + chartPoint.actual = cause.actual; + chartPoint.typical = cause.typical; + } + } + } + + if (record.multi_bucket_impact !== undefined) { + chartPoint.multiBucketImpact = record.multi_bucket_impact; + } + } + }); + + // Add a scheduledEvents property to any points in the chart data set + // which correspond to times of scheduled events for the job. + if (scheduledEvents !== undefined) { + each(scheduledEvents, (events, time) => { + const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); + if (chartPoint !== undefined) { + // Note if the scheduled event coincides with an absence of the underlying metric data, + // we don't worry about plotting the event. + chartPoint.scheduledEvents = events; + } + }); + } + + return chartData; + } + + function getChartDataForPointSearch( + chartData: ChartPoint[], + record: AnomalyRecordDoc, + chartType: ChartType + ) { + if ( + chartType === CHART_TYPE.EVENT_DISTRIBUTION || + chartType === CHART_TYPE.POPULATION_DISTRIBUTION + ) { + return chartData.filter((d) => { + return d.entity === (record && (record.by_field_value || record.over_field_value)); + }); + } + + return chartData; + } + + function findChartPointForTime(chartData: ChartPoint[], time: number) { + return chartData.find((point) => point.date === time); + } + + return Promise.all(seriesPromises) + .then((response) => { + // calculate an overall min/max for all series + const processedData = response.map(processChartData); + const allDataPoints = reduce( + processedData, + (datapoints, series) => { + each(series, (d) => datapoints.push(d)); + return datapoints; + }, + [] as ChartPoint[] + ); + const overallChartLimits = chartLimits(allDataPoints); + + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesConfigsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } + if (explorerService) { + explorerService.setCharts({ ...data }); + } + return Promise.resolve(data); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + } + + public processRecordsForDisplay( + jobRecords: Record, + anomalyRecords: RecordForInfluencer[] + ): { records: ChartRecord[]; errors: Record> | undefined } { + // Aggregate the anomaly data by detector, and entity (by/over/partition). + if (anomalyRecords.length === 0) { + return { records: [], errors: undefined }; + } + // Aggregate by job, detector, and analysis fields (partition, by, over). + const aggregatedData: Record = {}; + + const jobsErrorMessage: Record = {}; + each(anomalyRecords, (record) => { + // Check if we can plot a chart for this record, depending on whether the source data + // is chartable, and if model plot is enabled for the job. + + const job = jobRecords[record.job_id]; + + // if we already know this job has datafeed aggregations we cannot support + // no need to do more checks + if (jobsErrorMessage[record.job_id] !== undefined) { + return; + } + + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + + if (isChartable === false) { + if (isModelPlotChartableForDetector(job, record.detector_index)) { + // Check if model plot is enabled for this job. + // Need to check the entity fields for the record in case the model plot config has a terms list. + const entityFields = getEntityFieldList(record); + if (isModelPlotEnabled(job, record.detector_index, entityFields)) { + isChartable = true; + } else { + isChartable = false; + jobsErrorMessage[record.job_id] = i18n.translate( + 'xpack.ml.timeSeriesJob.sourceDataNotChartableWithDisabledModelPlotMessage', + { + defaultMessage: + 'source data is not viewable for this detector and model plot is disabled', + } + ); + } + } else { + jobsErrorMessage[record.job_id] = i18n.translate( + 'xpack.ml.timeSeriesJob.sourceDataModelPlotNotChartableMessage', + { + defaultMessage: 'both source data and model plot are not chartable for this detector', + } + ); + } + } + + if (isChartable === false) { + return; + } + const jobId = record.job_id; + if (aggregatedData[jobId] === undefined) { + aggregatedData[jobId] = {}; + } + const detectorsForJob = aggregatedData[jobId]; + + const detectorIndex = record.detector_index; + if (detectorsForJob[detectorIndex] === undefined) { + detectorsForJob[detectorIndex] = {}; + } + + // TODO - work out how best to display results from detectors with just an over field. + const firstFieldName = + record.partition_field_name || record.by_field_name || record.over_field_name; + const firstFieldValue = + record.partition_field_value || record.by_field_value || record.over_field_value; + if (firstFieldName !== undefined && firstFieldValue !== undefined) { + const groupsForDetector = detectorsForJob[detectorIndex]; + + if (groupsForDetector[firstFieldName] === undefined) { + groupsForDetector[firstFieldName] = {}; + } + const valuesForGroup: Record = groupsForDetector[firstFieldName]; + if (valuesForGroup[firstFieldValue] === undefined) { + valuesForGroup[firstFieldValue] = {}; + } + + const dataForGroupValue = valuesForGroup[firstFieldValue]; + + let isSecondSplit = false; + if (record.partition_field_name !== undefined) { + const splitFieldName = record.over_field_name || record.by_field_name; + if (splitFieldName !== undefined) { + isSecondSplit = true; + } + } + + if (isSecondSplit === false) { + if (dataForGroupValue.maxScoreRecord === undefined) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForGroupValue.maxScore) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } + } + } else { + // Aggregate another level for the over or by field. + const secondFieldName = record.over_field_name || record.by_field_name; + const secondFieldValue = record.over_field_value || record.by_field_value; + + if (secondFieldName !== undefined && secondFieldValue !== undefined) { + if (dataForGroupValue[secondFieldName] === undefined) { + dataForGroupValue[secondFieldName] = {}; + } + + const splitsForGroup = dataForGroupValue[secondFieldName]; + if (splitsForGroup[secondFieldValue] === undefined) { + splitsForGroup[secondFieldValue] = {}; + } + + const dataForSplitValue = splitsForGroup[secondFieldValue]; + if (dataForSplitValue.maxScoreRecord === undefined) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForSplitValue.maxScore) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } + } + } + } + } else { + // Detector with no partition or by field. + const dataForDetector = detectorsForJob[detectorIndex]; + if (dataForDetector.maxScoreRecord === undefined) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; + } else { + if (record.record_score > dataForDetector.maxScore) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; + } + } + } + }); + + // Group job id by error message instead of by job: + const errorMessages: Record> | undefined = {}; + Object.keys(jobsErrorMessage).forEach((jobId) => { + const msg = jobsErrorMessage[jobId]; + if (errorMessages[msg] === undefined) { + errorMessages[msg] = new Set([jobId]); + } else { + errorMessages[msg].add(jobId); + } + }); + let recordsForSeries: ChartRecord[] = []; + // Convert to an array of the records with the highest record_score per unique series. + each(aggregatedData, (detectorsForJob) => { + each(detectorsForJob, (groupsForDetector) => { + if (groupsForDetector.errorMessage !== undefined) { + recordsForSeries.push(groupsForDetector.errorMessage); + } else { + if (groupsForDetector.maxScoreRecord !== undefined) { + // Detector with no partition / by field. + recordsForSeries.push(groupsForDetector.maxScoreRecord); + } else { + each(groupsForDetector, (valuesForGroup) => { + each(valuesForGroup, (dataForGroupValue) => { + if (dataForGroupValue.maxScoreRecord !== undefined) { + recordsForSeries.push(dataForGroupValue.maxScoreRecord); + } else { + // Second level of aggregation for partition and by/over. + each(dataForGroupValue, (splitsForGroup) => { + each(splitsForGroup, (dataForSplitValue) => { + recordsForSeries.push(dataForSplitValue.maxScoreRecord); + }); + }); + } + }); + }); + } + } + }); + }); + recordsForSeries = sortBy(recordsForSeries, 'record_score').reverse(); + + return { records: recordsForSeries, errors: errorMessages }; + } +} diff --git a/x-pack/plugins/ml/public/application/services/ml_results_service.ts b/x-pack/plugins/ml/public/application/services/ml_results_service.ts new file mode 100644 index 0000000000000..aafeb23f11f65 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/ml_results_service.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const createMlResultsServiceMock = () => ({ + getMetricData: jest.fn(), + getModelPlotOutput: jest.fn(), + getRecordsForCriteria: jest.fn(), + getScheduledEventsByBucket: jest.fn(), + fetchPartitionFieldsValues: jest.fn(), + getEventDistributionData: jest.fn(), +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index a8ae42658f368..e07d49ca23d3b 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -69,8 +69,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { index: string, entityFields: any[], query: object | undefined, - metricFunction: string, // ES aggregation name - metricFieldName: string, + metricFunction: string | null, // ES aggregation name + metricFieldName: string | undefined, summaryCountFieldName: string | undefined, timeFieldName: string, earliestMs: number, @@ -243,7 +243,7 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { getModelPlotOutput( jobId: string, detectorIndex: number, - criteriaFields: any[], + criteriaFields: CriteriaField[], earliestMs: number, latestMs: number, intervalMs: number, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index f9a2c1389c828..bb0cdc89904f8 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -7,7 +7,11 @@ import { IndicesOptions } from '../../../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../ml_api_service'; +import type { AnomalyRecordDoc } from '../../../../common/types/anomalies'; +import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import { EntityField } from '../../../../common/util/anomaly_utils'; +type RecordForInfluencer = AnomalyRecordDoc; export function resultsServiceProvider( mlApiServices: MlApiServices ): { @@ -27,7 +31,7 @@ export function resultsServiceProvider( perPage?: number, fromPage?: number, influencers?: any[], - influencersFilterQuery?: any + influencersFilterQuery?: InfluencersFilterQuery ): Promise; getTopInfluencerValues(): Promise; getOverallBucketScores( @@ -47,10 +51,10 @@ export function resultsServiceProvider( maxResults: number, perPage: number, fromPage: number, - influencersFilterQuery: any + influencersFilterQuery: InfluencersFilterQuery ): Promise; getRecordInfluencers(): Promise; - getRecordsForInfluencer(): Promise; + getRecordsForInfluencer(): Promise; getRecordsForDetector(): Promise; getRecords(): Promise; getEventRateData( @@ -64,11 +68,11 @@ export function resultsServiceProvider( ): Promise; getEventDistributionData( index: string, - splitField: string, - filterField: string, + splitField: EntityField | undefined | null, + filterField: EntityField | undefined | null, query: any, - metricFunction: string, // ES aggregation name - metricFieldName: string, + metricFunction: string | undefined | null, // ES aggregation name + metricFieldName: string | undefined, timeFieldName: string, earliestMs: number, latestMs: number, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 502692da39c96..fa0bcd6ea987d 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -1232,7 +1232,11 @@ export function resultsServiceProvider(mlApiServices) { }, }; - if (metricFieldName !== undefined && metricFieldName !== '') { + if ( + metricFieldName !== undefined && + metricFieldName !== '' && + typeof metricFunction === 'string' + ) { body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; const metricAgg = { diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index 9c4e56e292ed0..658926a5a96a9 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -6,8 +6,7 @@ */ import React from 'react'; - -import { TimeRangeBounds } from '../explorer/explorer_utils'; +import { TimeRangeBounds } from '../util/time_buckets'; interface Props { appStateHandler: (action: string, payload: any) => void; diff --git a/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts b/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts new file mode 100644 index 0000000000000..70e756933b86e --- /dev/null +++ b/x-pack/plugins/ml/public/application/util/__mocks__/time_buckets.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const timeBucketsMock = { + setBarTarget: jest.fn(), + setMaxBars: jest.fn(), + setInterval: jest.fn(), + setBounds: jest.fn(), + getBounds: jest.fn(), + getInterval: jest.fn(), + getScaledDateFormat: jest.fn(), +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts index 1c94cc6e82f8b..ee85525ec00f4 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts +++ b/x-pack/plugins/ml/public/application/util/chart_utils.d.ts @@ -5,4 +5,13 @@ * 2.0. */ +import type { ChartType } from '../explorer/explorer_constants'; + export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number; +export declare function getChartType(config: any): ChartType; +export declare function chartLimits( + data: any[] +): { + min: number; + max: number; +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 5ffe2fe86ec32..9b5cab41f24e2 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -9,7 +9,6 @@ import d3 from 'd3'; import { calculateTextWidth } from './string_utils'; import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact'; import moment from 'moment'; -import { getTimefilter } from './dependency_cache'; import { CHART_TYPE } from '../explorer/explorer_constants'; import { ML_PAGES } from '../../../common/constants/ml_url_generator'; @@ -220,10 +219,9 @@ export function getChartType(config) { return chartType; } -export async function getExploreSeriesLink(mlUrlGenerator, series) { +export async function getExploreSeriesLink(mlUrlGenerator, series, timefilter) { // Open the Single Metric dashboard over the same overall bounds and // zoomed in to the same time as the current chart. - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z const to = bounds.max.toISOString(); diff --git a/x-pack/plugins/ml/public/application/util/recently_accessed.ts b/x-pack/plugins/ml/public/application/util/recently_accessed.ts index 0967d4a0587e3..88f78946bf7b4 100644 --- a/x-pack/plugins/ml/public/application/util/recently_accessed.ts +++ b/x-pack/plugins/ml/public/application/util/recently_accessed.ts @@ -9,9 +9,15 @@ import { i18n } from '@kbn/i18n'; +import type { ChromeRecentlyAccessed } from 'kibana/public'; import { getRecentlyAccessed } from './dependency_cache'; -export function addItemToRecentlyAccessed(page: string, itemId: string, url: string) { +export function addItemToRecentlyAccessed( + page: string, + itemId: string, + url: string, + recentlyAccessedService?: ChromeRecentlyAccessed +) { let pageLabel = ''; let id = `ml-job-${itemId}`; @@ -39,6 +45,6 @@ export function addItemToRecentlyAccessed(page: string, itemId: string, url: str } url = url.startsWith('/') ? `/app/ml${url}` : `/app/ml/${page}/${url}`; - const recentlyAccessed = getRecentlyAccessed(); + const recentlyAccessed = recentlyAccessedService ?? getRecentlyAccessed(); recentlyAccessed.add(url, `ML - ${itemId} - ${pageLabel}`, id); } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap new file mode 100644 index 0000000000000..375b041c4db73 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/__snapshots__/embeddable_anomaly_charts_container.test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmbeddableAnomalyChartsContainer should render explorer charts with a valid embeddable input 1`] = ` +Object { + "chartsData": Object { + "chartsPerRow": 2, + "errorMessages": undefined, + "seriesToPlot": Array [], + "timeFieldName": "@timestamp", + "tooManyBuckets": false, + }, + "id": "test-explorer-charts-embeddable", + "mlUrlGenerator": undefined, + "onSelectEntity": [Function], + "setSeverity": [Function], + "severity": Object { + "color": "#fe5050", + "display": "critical", + "val": 75, + }, + "showCharts": true, + "timeBuckets": TimeBuckets { + "_timeBucketsConfig": Object { + "dateFormat": undefined, + "dateFormat:scaled": undefined, + "histogram:barTarget": undefined, + "histogram:maxBars": undefined, + }, + "barTarget": undefined, + "maxBars": undefined, + }, + "timefilter": Object { + "calculateBounds": [MockFunction], + "createFilter": [MockFunction], + "disableAutoRefreshSelector": [MockFunction], + "disableTimeRangeSelector": [MockFunction], + "enableAutoRefreshSelector": [MockFunction], + "enableTimeRangeSelector": [MockFunction], + "getAbsoluteTime": [MockFunction], + "getActiveBounds": [MockFunction], + "getAutoRefreshFetch$": [MockFunction], + "getBounds": [MockFunction], + "getEnabledUpdated$": [MockFunction], + "getFetch$": [MockFunction], + "getRefreshInterval": [MockFunction], + "getRefreshIntervalDefaults": [MockFunction], + "getRefreshIntervalUpdate$": [MockFunction], + "getTime": [MockFunction], + "getTimeDefaults": [MockFunction], + "getTimeUpdate$": [MockFunction], + "isAutoRefreshSelectorEnabled": [MockFunction], + "isTimeRangeSelectorEnabled": [MockFunction], + "isTimeTouched": [MockFunction], + "setRefreshInterval": [MockFunction], + "setTime": [MockFunction], + }, +} +`; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx new file mode 100644 index 0000000000000..298abd4dcc241 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { Subject } from 'rxjs'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { Embeddable, IContainer } from '../../../../../../src/plugins/embeddable/public'; +import { EmbeddableAnomalyChartsContainer } from './embeddable_anomaly_charts_container_lazy'; +import type { JobId } from '../../../common/types/anomaly_detection_jobs'; +import type { MlDependencies } from '../../application/app'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsServices, +} from '..'; +import type { IndexPattern } from '../../../../../../src/plugins/data/common/index_patterns'; +import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; +export const getDefaultExplorerChartsPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.anomalyChartsEmbeddable.title', { + defaultMessage: 'ML anomaly charts for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + +export type IAnomalyChartsEmbeddable = typeof AnomalyChartsEmbeddable; + +export class AnomalyChartsEmbeddable extends Embeddable< + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput +> { + private node?: HTMLElement; + private reload$ = new Subject(); + public readonly type: string = ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; + + constructor( + initialInput: AnomalyChartsEmbeddableInput, + public services: [CoreStart, MlDependencies, AnomalyChartsServices], + parent?: IContainer + ) { + super( + initialInput, + { + defaultTitle: initialInput.title, + }, + parent + ); + this.initializeOutput(initialInput); + } + + private async initializeOutput(initialInput: AnomalyChartsEmbeddableInput) { + const { anomalyExplorerService } = this.services[2]; + const { jobIds } = initialInput; + + try { + const jobs = await anomalyExplorerService.getCombinedJobs(jobIds); + const indexPatternsService = this.services[1].data.indexPatterns; + + // First get list of unique indices from the selected jobs + const indices = new Set(jobs.map((j) => j.datafeed_config.indices).flat()); + + // Then find the index patterns assuming the index pattern title matches the index name + const indexPatterns: Record = {}; + for (const indexName of indices) { + const response = await indexPatternsService.find(`"${indexName}"`); + + const indexPattern = response.find( + (obj) => obj.title.toLowerCase() === indexName.toLowerCase() + ); + if (indexPattern !== undefined) { + indexPatterns[indexPattern.id!] = indexPattern; + } + } + + this.updateOutput({ + ...this.getOutput(), + indexPatterns: Object.values(indexPatterns), + }); + } catch (e) { + // Unable to find and load index pattern but we can ignore the error + // as we only load it to support the filter & query bar + // the visualizations should still work correctly + + // eslint-disable-next-line no-console + console.error(`Unable to load index patterns for ${jobIds}`, e); + } + } + + public render(node: HTMLElement) { + super.render(node); + this.node = node; + + const I18nContext = this.services[0].i18n.Context; + + ReactDOM.render( + + + }> + + + + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() { + this.reload$.next(); + } + + public supportedTriggers() { + return []; + } +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.test.ts new file mode 100644 index 0000000000000..441ac145e1bd4 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { AnomalyChartsEmbeddableFactory } from './anomaly_charts_embeddable_factory'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +import { AnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import { AnomalyChartsEmbeddableInput } from '..'; + +jest.mock('./anomaly_charts_embeddable', () => ({ + AnomalyChartsEmbeddable: jest.fn(), +})); + +describe('AnomalyChartsEmbeddableFactory', () => { + test('should provide required services on create', async () => { + // arrange + const pluginStartDeps = { data: dataPluginMock.createStartContract() }; + + const getStartServices = coreMock.createSetup({ + pluginStartDeps, + }).getStartServices; + + const [coreStart, pluginsStart] = await getStartServices(); + + // act + const factory = new AnomalyChartsEmbeddableFactory(getStartServices); + + await factory.create({ + jobIds: ['test-job'], + maxSeriesToPlot: 4, + } as AnomalyChartsEmbeddableInput); + + // assert + const mockCalls = ((AnomalyChartsEmbeddable as unknown) as jest.Mock) + .mock.calls[0]; + const input = mockCalls[0]; + const createServices = mockCalls[1]; + + expect(input).toEqual({ + jobIds: ['test-job'], + maxSeriesToPlot: 4, + }); + expect(Object.keys(createServices[0])).toEqual(Object.keys(coreStart)); + expect(createServices[1]).toMatchObject(pluginsStart); + expect(Object.keys(createServices[2])).toEqual([ + 'anomalyDetectorService', + 'anomalyExplorerService', + 'mlResultsService', + ]); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts new file mode 100644 index 0000000000000..ac5ff2094e22b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_embeddable_factory.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +import type { StartServicesAccessor } from 'kibana/public'; + +import type { + EmbeddableFactoryDefinition, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { HttpService } from '../../application/services/http_service'; +import type { MlPluginStart, MlStartDependencies } from '../../plugin'; +import type { MlDependencies } from '../../application/app'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableServices, +} from '..'; +import { AnomalyExplorerChartsService } from '../../application/services/anomaly_explorer_charts_service'; + +export class AnomalyChartsEmbeddableFactory + implements EmbeddableFactoryDefinition { + public readonly type = ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE; + + constructor( + private getStartServices: StartServicesAccessor + ) {} + + public async isEditable() { + return true; + } + + public getDisplayName() { + return i18n.translate('xpack.ml.components.mlAnomalyExplorerEmbeddable.displayName', { + defaultMessage: 'ML anomaly chart', + }); + } + + public async getExplicitInput(): Promise> { + const [coreStart] = await this.getServices(); + + try { + const { resolveEmbeddableAnomalyChartsUserInput } = await import( + './anomaly_charts_setup_flyout' + ); + return await resolveEmbeddableAnomalyChartsUserInput(coreStart); + } catch (e) { + return Promise.reject(); + } + } + + private async getServices(): Promise { + const [coreStart, pluginsStart] = await this.getStartServices(); + + const { AnomalyDetectorService } = await import( + '../../application/services/anomaly_detector_service' + ); + const { mlApiServicesProvider } = await import('../../application/services/ml_api_service'); + const { mlResultsServiceProvider } = await import('../../application/services/results_service'); + + const httpService = new HttpService(coreStart.http); + const anomalyDetectorService = new AnomalyDetectorService(httpService); + const mlApiServices = mlApiServicesProvider(httpService); + const mlResultsService = mlResultsServiceProvider(mlApiServices); + + const anomalyExplorerService = new AnomalyExplorerChartsService( + pluginsStart.data.query.timefilter.timefilter, + mlApiServices, + mlResultsService + ); + + return [ + coreStart, + pluginsStart as MlDependencies, + { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + ]; + } + + public async create(initialInput: AnomalyChartsEmbeddableInput, parent?: IContainer) { + const services = await this.getServices(); + const { AnomalyChartsEmbeddable } = await import('./anomaly_charts_embeddable'); + return new AnomalyChartsEmbeddable(initialInput, services, parent); + } +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx new file mode 100644 index 0000000000000..1473a599c2c4b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AnomalyChartsInitializer } from './anomaly_charts_initializer'; +import { I18nProvider } from '@kbn/i18n/react'; +import React from 'react'; +import { getDefaultExplorerChartsPanelTitle } from './anomaly_charts_embeddable'; +const defaultOptions = { wrapper: I18nProvider }; + +describe('AnomalyChartsInitializer', () => { + test('should render anomaly charts initializer', async () => { + const onCreate = jest.fn(); + const onCancel = jest.fn(); + + const jobIds = ['test-job']; + const defaultTitle = getDefaultExplorerChartsPanelTitle(jobIds); + const input = { + maxSeriesToPlot: 12, + }; + const { getByTestId } = render( + onCreate(params)} + onCancel={onCancel} + />, + defaultOptions + ); + const confirmButton = screen.getByText(/Confirm/i).closest('button'); + expect(confirmButton).toBeDefined(); + expect(onCreate).toHaveBeenCalledTimes(0); + + userEvent.click(confirmButton!); + expect(onCreate).toHaveBeenCalledWith({ + panelTitle: defaultTitle, + maxSeriesToPlot: input.maxSeriesToPlot, + }); + + userEvent.clear(await getByTestId('panelTitleInput')); + expect(confirmButton).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx new file mode 100644 index 0000000000000..f32446fd6d9ab --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_initializer.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiFieldNumber, + EuiFieldText, + EuiModal, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AnomalyChartsEmbeddableInput } from '..'; +import { DEFAULT_MAX_SERIES_TO_PLOT } from '../../application/services/anomaly_explorer_charts_service'; + +const MAX_SERIES_ALLOWED = 48; +export interface AnomalyChartsInitializerProps { + defaultTitle: string; + initialInput?: Partial>; + onCreate: (props: { panelTitle: string; maxSeriesToPlot?: number }) => void; + onCancel: () => void; +} + +export const AnomalyChartsInitializer: FC = ({ + defaultTitle, + initialInput, + onCreate, + onCancel, +}) => { + const [panelTitle, setPanelTitle] = useState(defaultTitle); + const [maxSeriesToPlot, setMaxSeriesToPlot] = useState( + initialInput?.maxSeriesToPlot ?? DEFAULT_MAX_SERIES_TO_PLOT + ); + + const isPanelTitleValid = panelTitle.length > 0; + + const isFormValid = isPanelTitleValid && maxSeriesToPlot > 0; + return ( + + + +

+ +

+
+
+ + + + + } + isInvalid={!isPanelTitleValid} + > + setPanelTitle(e.target.value)} + isInvalid={!isPanelTitleValid} + /> + + + + } + > + setMaxSeriesToPlot(parseInt(e.target.value, 10))} + min={0} + max={MAX_SERIES_ALLOWED} + /> + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx new file mode 100644 index 0000000000000..eb39ba4ab29aa --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/anomaly_charts_setup_flyout.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { CoreStart } from 'kibana/public'; +import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; +import { getDefaultExplorerChartsPanelTitle } from './anomaly_charts_embeddable'; +import { HttpService } from '../../application/services/http_service'; +import { AnomalyChartsEmbeddableInput } from '..'; +import { resolveJobSelection } from '../common/resolve_job_selection'; +import { AnomalyChartsInitializer } from './anomaly_charts_initializer'; + +export async function resolveEmbeddableAnomalyChartsUserInput( + coreStart: CoreStart, + input?: AnomalyChartsEmbeddableInput +): Promise> { + const { http, overlays } = coreStart; + + const anomalyDetectorService = new AnomalyDetectorService(new HttpService(http)); + + return new Promise(async (resolve, reject) => { + const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); + + const title = input?.title ?? getDefaultExplorerChartsPanelTitle(jobIds); + const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); + const influencers = anomalyDetectorService.extractInfluencers(jobs); + influencers.push(VIEW_BY_JOB_LABEL); + + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + + resolve({ + jobIds, + title: panelTitle, + maxSeriesToPlot, + }); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + ) + ); + }); +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx new file mode 100644 index 0000000000000..7e4e91eb2ad0e --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { + EmbeddableAnomalyChartsContainer, + EmbeddableAnomalyChartsContainerProps, +} from './embeddable_anomaly_charts_container'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { I18nProvider } from '@kbn/i18n/react'; +import { AnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import { CoreStart } from 'kibana/public'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import { MlDependencies } from '../../application/app'; +import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; +import { AnomalyChartsEmbeddableInput, AnomalyChartsServices } from '..'; +import { ExplorerAnomaliesContainer } from '../../application/explorer/explorer_charts/explorer_anomalies_container'; +import { createMlResultsServiceMock } from '../../application/services/ml_results_service'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; +import { createAnomalyExplorerChartsServiceMock } from '../../application/services/__mocks__/anomaly_explorer_charts_service'; +import { createAnomalyDetectorServiceMock } from '../../application/services/__mocks__/anomaly_detector_service'; + +jest.mock('./use_anomaly_charts_input_resolver', () => ({ + useAnomalyChartsInputResolver: jest.fn(() => { + return []; + }), +})); + +jest.mock('../../application/explorer/explorer_charts/explorer_anomalies_container', () => ({ + ExplorerAnomaliesContainer: jest.fn(() => { + return null; + }), +})); + +const defaultOptions = { wrapper: I18nProvider }; + +describe('EmbeddableAnomalyChartsContainer', () => { + let embeddableInput: BehaviorSubject>; + let refresh: BehaviorSubject; + let services: jest.Mocked<[CoreStart, MlDependencies, AnomalyChartsServices]>; + let embeddableContext: jest.Mocked; + let trigger: jest.Mocked; + + const onInputChange = jest.fn(); + const onOutputChange = jest.fn(); + + const mockedInput = { + viewMode: 'view', + filters: [], + hidePanelTitles: false, + query: { + language: 'lucene', + query: 'instance:i-d**', + }, + timeRange: { + from: 'now-3y', + to: 'now', + }, + refreshConfig: { + value: 0, + pause: true, + }, + id: 'b5b2f600-9c7e-4f7d-8b82-ee156fffad27', + searchSessionId: 'e8d052f8-0d9a-4d80-819d-fe18d9b314fa', + syncColors: true, + title: 'ML anomaly explorer charts for cw_multi_1', + jobIds: ['cw_multi_1'], + maxSeriesToPlot: 12, + enhancements: {}, + severity: 50, + severityThreshold: 75, + } as AnomalyChartsEmbeddableInput; + + beforeEach(() => { + // we only want to mock some of the functions needed + // @ts-ignore + embeddableContext = { + id: 'test-id', + getInput: jest.fn(), + }; + embeddableContext.getInput.mockReturnValue(mockedInput); + + embeddableInput = new BehaviorSubject({ + id: 'test-explorer-charts-embeddable', + } as Partial); + + trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked; + + const mlStartMock = createMlStartDepsMock(); + mlStartMock.uiActions.getTrigger.mockReturnValue(trigger); + + const coreStartMock = createCoreStartMock(); + const anomalyDetectorServiceMock = createAnomalyDetectorServiceMock(); + + anomalyDetectorServiceMock.getJobs$.mockImplementation((jobId: string[]) => { + if (jobId.includes('invalid-job-id')) { + throw new Error('Invalid job'); + } + return of([ + { + job_id: 'cw_multi_1', + analysis_config: { bucket_span: '15m' }, + }, + ]); + }); + + services = ([ + coreStartMock, + mlStartMock, + { + anomalyDetectorService: anomalyDetectorServiceMock, + anomalyExplorerChartsService: createAnomalyExplorerChartsServiceMock(), + mlResultsService: createMlResultsServiceMock(), + }, + ] as unknown) as EmbeddableAnomalyChartsContainerProps['services']; + }); + + test('should render explorer charts with a valid embeddable input', async () => { + const chartsData = { + chartsPerRow: 2, + seriesToPlot: [], + tooManyBuckets: false, + timeFieldName: '@timestamp', + errorMessages: undefined, + }; + + (useAnomalyChartsInputResolver as jest.Mock).mockReturnValueOnce({ + chartsData, + isLoading: false, + error: undefined, + }); + + render( + } + services={services} + refresh={refresh} + onInputChange={onInputChange} + onOutputChange={onOutputChange} + />, + defaultOptions + ); + + const calledWith = ((ExplorerAnomaliesContainer as unknown) as jest.Mock< + typeof ExplorerAnomaliesContainer + >).mock.calls[0][0]; + + expect(calledWith).toMatchSnapshot(); + }); + + test('should render an error in case it could not fetch the ML charts data', async () => { + (useAnomalyChartsInputResolver as jest.Mock).mockReturnValueOnce({ + chartsData: undefined, + isLoading: false, + error: 'No anomalies', + }); + + const { findByText } = render( + } + services={services} + refresh={refresh} + onInputChange={onInputChange} + onOutputChange={onOutputChange} + />, + defaultOptions + ); + const errorMessage = await findByText('Unable to load the ML anomaly explorer data'); + expect(errorMessage).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx new file mode 100644 index 0000000000000..e1748bd21855b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useState, useMemo, useEffect } from 'react'; +import { EuiCallOut, EuiLoadingChart, EuiResizeObserver, EuiText } from '@elastic/eui'; +import { Observable } from 'rxjs'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { throttle } from 'lodash'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import type { IAnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; +import type { + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsEmbeddableServices, +} from '..'; +import type { EntityField, EntityFieldOperation } from '../../../common/util/anomaly_utils'; + +import { ExplorerAnomaliesContainer } from '../../application/explorer/explorer_charts/explorer_anomalies_container'; +import { ML_APP_URL_GENERATOR } from '../../../common/constants/ml_url_generator'; +import { optionValueToThreshold } from '../../application/components/controls/select_severity/select_severity'; +import { ANOMALY_THRESHOLD } from '../../../common'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; +import { TimeBuckets } from '../../application/util/time_buckets'; +import { EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER } from '../../ui_actions/triggers'; + +const RESIZE_THROTTLE_TIME_MS = 500; + +export interface EmbeddableAnomalyChartsContainerProps { + id: string; + embeddableContext: InstanceType; + embeddableInput: Observable; + services: AnomalyChartsEmbeddableServices; + refresh: Observable; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; +} + +export const EmbeddableAnomalyChartsContainer: FC = ({ + id, + embeddableContext, + embeddableInput, + services, + refresh, + onInputChange, + onOutputChange, +}) => { + const [chartWidth, setChartWidth] = useState(0); + const [severity, setSeverity] = useState( + optionValueToThreshold( + embeddableContext.getInput().severityThreshold ?? ANOMALY_THRESHOLD.WARNING + ) + ); + const [selectedEntities, setSelectedEntities] = useState(); + const [ + { uiSettings }, + { + data: dataServices, + share: { + urlGenerators: { getUrlGenerator }, + }, + uiActions, + }, + ] = services; + const { timefilter } = dataServices.query.timefilter; + + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, []); + + useEffect(() => { + onInputChange({ + severityThreshold: severity.val, + }); + onOutputChange({ + severity: severity.val, + entityFields: selectedEntities, + }); + }, [severity, selectedEntities]); + + const { chartsData, isLoading: isExplorerLoading, error } = useAnomalyChartsInputResolver( + embeddableInput, + onInputChange, + refresh, + services, + chartWidth, + severity.val + ); + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + setChartWidth(e.width); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); + + if (error) { + return ( + + } + color="danger" + iconType="alert" + style={{ width: '100%' }} + > +

{error.message}

+
+ ); + } + + const addEntityFieldFilter = ( + fieldName: string, + fieldValue: string, + operation: EntityFieldOperation + ) => { + const entity: EntityField = { + fieldName, + fieldValue, + operation, + }; + const uniqueSelectedEntities = [entity]; + setSelectedEntities(uniqueSelectedEntities); + uiActions.getTrigger(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER).exec({ + embeddable: embeddableContext, + data: uniqueSelectedEntities, + }); + }; + + return ( + + {(resizeRef) => ( +
+ {isExplorerLoading && ( + + + + )} + {chartsData !== undefined && isExplorerLoading === false && ( + + )} +
+ )} +
+ ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default EmbeddableAnomalyChartsContainer; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx new file mode 100644 index 0000000000000..38f48ea4a018b --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container_lazy.tsx @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +export const EmbeddableAnomalyChartsContainer = React.lazy( + () => import('./embeddable_anomaly_charts_container') +); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/index.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/index.ts new file mode 100644 index 0000000000000..7ee763b893367 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/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 { AnomalyChartsEmbeddableFactory } from './anomaly_charts_embeddable_factory'; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts new file mode 100644 index 0000000000000..efac51edda69f --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.test.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { AnomalyChartsEmbeddableInput, AnomalyChartsServices } from '../types'; +import { CoreStart } from 'kibana/public'; +import { MlStartDependencies } from '../../plugin'; +import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; +import { EmbeddableAnomalyChartsContainerProps } from './embeddable_anomaly_charts_container'; +import moment from 'moment'; +import { createMlResultsServiceMock } from '../../application/services/ml_results_service'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; +import { createAnomalyExplorerChartsServiceMock } from '../../application/services/__mocks__/anomaly_explorer_charts_service'; +import { createAnomalyDetectorServiceMock } from '../../application/services/__mocks__/anomaly_detector_service'; + +jest.mock('../common/process_filters', () => ({ + processFilters: jest.fn(), +})); + +jest.mock('../../application/explorer/explorer_utils', () => ({ + getSelectionInfluencers: jest.fn(() => { + return []; + }), + getSelectionJobIds: jest.fn(() => ['test-job']), + getSelectionTimeRange: jest.fn(() => ({ earliestMs: 1521309543000, latestMs: 1616003942999 })), + loadDataForCharts: jest.fn().mockImplementation(() => + Promise.resolve([ + { + job_id: 'cw_multi_1', + result_type: 'record', + probability: 6.057139142746412e-13, + multi_bucket_impact: -5, + record_score: 89.71961, + initial_record_score: 98.36826274948001, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1572892200000, + partition_field_name: 'instance', + partition_field_value: 'i-d17dcd4c', + function: 'mean', + function_description: 'mean', + typical: [1.6177685422858146], + actual: [7.235333333333333], + field_name: 'CPUUtilization', + influencers: [ + { + influencer_field_name: 'region', + influencer_field_values: ['sa-east-1'], + }, + { + influencer_field_name: 'instance', + influencer_field_values: ['i-d17dcd4c'], + }, + ], + instance: ['i-d17dcd4c'], + region: ['sa-east-1'], + }, + ]) + ), +})); + +describe('useAnomalyChartsInputResolver', () => { + let embeddableInput: BehaviorSubject>; + let refresh: Subject; + let services: [CoreStart, MlStartDependencies, AnomalyChartsServices]; + let onInputChange: jest.Mock; + + const start = moment().subtract(1, 'years'); + const end = moment(); + + beforeEach(() => { + jest.useFakeTimers(); + + const jobIds = ['test-job']; + embeddableInput = new BehaviorSubject({ + id: 'test-explorer-charts-embeddable', + jobIds, + filters: [], + query: { language: 'kuery', query: '' }, + maxSeriesToPlot: 12, + timeRange: { + from: 'now-3y', + to: 'now', + }, + } as Partial); + + refresh = new Subject(); + const anomalyExplorerChartsServiceMock = createAnomalyExplorerChartsServiceMock(); + + anomalyExplorerChartsServiceMock.getTimeBounds.mockReturnValue({ + min: start, + max: end, + }); + + anomalyExplorerChartsServiceMock.getCombinedJobs.mockImplementation(() => + Promise.resolve( + jobIds.map((jobId) => ({ job_id: jobId, analysis_config: {}, datafeed_config: {} })) + ) + ); + + anomalyExplorerChartsServiceMock.getAnomalyData.mockImplementation(() => + Promise.resolve({ + chartsPerRow: 2, + seriesToPlot: [], + tooManyBuckets: false, + timeFieldName: '@timestamp', + errorMessages: undefined, + }) + ); + + const coreStartMock = createCoreStartMock(); + const mlStartMock = createMlStartDepsMock(); + + const anomalyDetectorServiceMock = createAnomalyDetectorServiceMock(); + anomalyDetectorServiceMock.getJobs$.mockImplementation((jobId: string[]) => { + if (jobId.includes('invalid-job-id')) { + throw new Error('Invalid job'); + } + return of([ + { + job_id: 'cw_multi_1', + analysis_config: { bucket_span: '15m' }, + }, + ]); + }); + + services = ([ + coreStartMock, + mlStartMock, + { + anomalyDetectorService: anomalyDetectorServiceMock, + anomalyExplorerService: anomalyExplorerChartsServiceMock, + mlResultsService: createMlResultsServiceMock(), + }, + ] as unknown) as EmbeddableAnomalyChartsContainerProps['services']; + + onInputChange = jest.fn(); + }); + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + test('should fetch jobs only when input job ids have been changed', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAnomalyChartsInputResolver( + embeddableInput as Observable, + onInputChange, + refresh, + services, + 1000, + 0 + ) + ); + + expect(result.current.chartsData).toBe(undefined); + expect(result.current.error).toBe(undefined); + expect(result.current.isLoading).toBe(true); + + await act(async () => { + jest.advanceTimersByTime(501); + await waitForNextUpdate(); + }); + + const explorerServices = services[2]; + + expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(1); + expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(1); + + await act(async () => { + embeddableInput.next({ + id: 'test-explorer-charts-embeddable', + jobIds: ['anotherJobId'], + filters: [], + query: { language: 'kuery', query: '' }, + maxSeriesToPlot: 6, + timeRange: { + from: 'now-3y', + to: 'now', + }, + }); + jest.advanceTimersByTime(501); + await waitForNextUpdate(); + }); + + expect(explorerServices.anomalyDetectorService.getJobs$).toHaveBeenCalledTimes(2); + expect(explorerServices.anomalyExplorerService.getAnomalyData).toHaveBeenCalledTimes(2); + }); + + test('should not complete the observable on error', async () => { + const { result } = renderHook(() => + useAnomalyChartsInputResolver( + embeddableInput as Observable, + onInputChange, + refresh, + services, + 1000, + 1 + ) + ); + + await act(async () => { + embeddableInput.next({ + id: 'test-explorer-charts-embeddable', + jobIds: ['invalid-job-id'], + filters: [], + query: { language: 'kuery', query: '' }, + } as Partial); + }); + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts new file mode 100644 index 0000000000000..b114ca89a3288 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/use_anomaly_charts_input_resolver.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { combineLatest, forkJoin, from, Observable, of, Subject } from 'rxjs'; +import { catchError, debounceTime, skipWhile, startWith, switchMap, tap } from 'rxjs/operators'; +import { CoreStart } from 'kibana/public'; +import { TimeBuckets } from '../../application/util/time_buckets'; +import { MlStartDependencies } from '../../plugin'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { + AppStateSelectedCells, + ExplorerJob, + getSelectionInfluencers, + getSelectionJobIds, + getSelectionTimeRange, + loadDataForCharts, +} from '../../application/explorer/explorer_utils'; +import { OVERALL_LABEL, SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { parseInterval } from '../../../common/util/parse_interval'; +import { + AnomalyChartsEmbeddableInput, + AnomalyChartsEmbeddableOutput, + AnomalyChartsServices, +} from '..'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import type { ExplorerChartsData } from '../../application/explorer/explorer_charts/explorer_charts_container_service'; +import { processFilters } from '../common/process_filters'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { getJobsObservable } from '../common/get_jobs_observable'; + +const FETCH_RESULTS_DEBOUNCE_MS = 500; + +export function useAnomalyChartsInputResolver( + embeddableInput: Observable, + onInputChange: (output: Partial) => void, + refresh: Observable, + services: [CoreStart, MlStartDependencies, AnomalyChartsServices], + chartWidth: number, + severity: number +): { chartsData: ExplorerChartsData; isLoading: boolean; error: Error | null | undefined } { + const [ + { uiSettings }, + { data: dataServices }, + { anomalyDetectorService, anomalyExplorerService, mlResultsService }, + ] = services; + const { timefilter } = dataServices.query.timefilter; + + const [chartsData, setChartsData] = useState(); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const chartWidth$ = useMemo(() => new Subject(), []); + const severity$ = useMemo(() => new Subject(), []); + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, []); + + useEffect(() => { + const subscription = combineLatest([ + getJobsObservable(embeddableInput, anomalyDetectorService, setError), + embeddableInput, + chartWidth$.pipe(skipWhile((v) => !v)), + severity$, + refresh.pipe(startWith(null)), + ]) + .pipe( + tap(setIsLoading.bind(null, true)), + debounceTime(FETCH_RESULTS_DEBOUNCE_MS), + switchMap(([jobs, input, embeddableContainerWidth, severityValue]) => { + if (!jobs) { + // couldn't load the list of jobs + return of(undefined); + } + + const { maxSeriesToPlot, timeRange: timeRangeInput, filters, query } = input; + + const viewBySwimlaneFieldName = OVERALL_LABEL; + + anomalyExplorerService.setTimeRange(timeRangeInput); + + const explorerJobs: ExplorerJob[] = jobs.map((job) => { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + return { + id: job.job_id, + selected: true, + bucketSpanSeconds: bucketSpan!.asSeconds(), + }; + }); + + let influencersFilterQuery: InfluencersFilterQuery; + try { + influencersFilterQuery = processFilters(filters, query); + } catch (e) { + // handle query syntax errors + setError(e); + return of(undefined); + } + + const bounds = anomalyExplorerService.getTimeBounds(); + + // Can be from input time range or from the timefilter bar + const selections: AppStateSelectedCells = { + lanes: [OVERALL_LABEL], + times: [bounds.min?.unix()!, bounds.max?.unix()!], + type: SWIMLANE_TYPE.OVERALL, + }; + + const selectionInfluencers = getSelectionInfluencers(selections, viewBySwimlaneFieldName); + + const jobIds = getSelectionJobIds(selections, explorerJobs); + + const bucketInterval = timeBuckets.getInterval(); + + const timeRange = getSelectionTimeRange(selections, bucketInterval.asSeconds(), bounds); + return forkJoin({ + combinedJobs: anomalyExplorerService.getCombinedJobs(jobIds), + anomalyChartRecords: loadDataForCharts( + mlResultsService, + jobIds, + timeRange.earliestMs, + timeRange.latestMs, + selectionInfluencers, + selections, + influencersFilterQuery, + false + ), + }).pipe( + switchMap(({ combinedJobs, anomalyChartRecords }) => { + const combinedJobRecords: Record< + string, + CombinedJob + > = (combinedJobs as CombinedJob[]).reduce((acc, job) => { + return { ...acc, [job.job_id]: job }; + }, {}); + + return forkJoin({ + chartsData: from( + anomalyExplorerService.getAnomalyData( + undefined, + combinedJobRecords, + embeddableContainerWidth, + anomalyChartRecords, + timeRange.earliestMs, + timeRange.latestMs, + timefilter, + severityValue, + maxSeriesToPlot + ) + ), + }); + }) + ); + }), + catchError((e) => { + setError(e.body); + return of(undefined); + }) + ) + .subscribe((results) => { + if (results !== undefined) { + setError(null); + setChartsData(results.chartsData); + setIsLoading(false); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + useEffect(() => { + chartWidth$.next(chartWidth); + }, [chartWidth]); + + useEffect(() => { + severity$.next(severity); + }, [severity]); + + return { chartsData, isLoading, error }; +} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index 50aa99e2b8d17..7f9e99f3a0c8e 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -22,8 +22,8 @@ import { AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, } from '..'; - -export const getDefaultPanelTitle = (jobIds: JobId[]) => +import { EmbeddableLoading } from '../common/components/embeddable_loading_fallback'; +export const getDefaultSwimlanePanelTitle = (jobIds: JobId[]) => i18n.translate('xpack.ml.swimlaneEmbeddable.title', { defaultMessage: 'ML anomaly swim lane for {jobIds}', values: { jobIds: jobIds.join(', ') }, @@ -62,7 +62,7 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< ReactDOM.render( - + }> { const { jobIds } = await resolveJobSelection(coreStart, input?.jobIds); - const title = input?.title ?? getDefaultPanelTitle(jobIds); + const title = input?.title ?? getDefaultSwimlanePanelTitle(jobIds); const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 560e373eb281c..00f4da09bbe0e 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx @@ -19,9 +19,10 @@ import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { SwimlaneContainer } from '../../application/explorer/swimlane_container'; import { MlDependencies } from '../../application/app'; -import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneServices } from '..'; +import { createCoreStartMock } from '../../__mocks__/core_start'; +import { createMlStartDepsMock } from '../../__mocks__/ml_start_deps'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -56,14 +57,12 @@ describe('ExplorerSwimlaneContainer', () => { trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked; - const uiActionsMock = uiActionsPluginMock.createStartContract(); - uiActionsMock.getTrigger.mockReturnValue(trigger); + const mlStartMock = createMlStartDepsMock(); + mlStartMock.uiActions.getTrigger.mockReturnValue(trigger); services = ([ - {}, - { - uiActions: uiActionsMock, - }, + createCoreStartMock(), + mlStartMock, ] as unknown) as ExplorerSwimlaneContainerProps['services']; }); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index fb47a2684a015..d671bff90b31f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -47,6 +47,7 @@ export const EmbeddableSwimLaneContainer: FC = ( onOutputChange, }) => { const [chartWidth, setChartWidth] = useState(0); + const [fromPage, setFromPage] = useState(1); const [{}, { uiActions }] = services; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts index 3fffd1588b9b9..4d2e2406376e2 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { processFilters, useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { CoreStart, IUiSettingsClient } from 'kibana/public'; @@ -157,146 +157,3 @@ describe('useSwimlaneInputResolver', () => { expect(result.current[6]?.message).toBe('Invalid job'); }); }); - -describe('processFilters', () => { - test('should format embeddable input to es query', () => { - expect( - processFilters( - [ - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - type: 'phrases', - key: 'instance', - value: 'i-20d061fa', - params: ['i-20d061fa'], - alias: null, - negate: false, - disabled: false, - }, - query: { - bool: { - should: [ - { - match_phrase: { - instance: 'i-20d061fa', - }, - }, - ], - minimum_should_match: 1, - }, - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: true, - disabled: false, - type: 'phrase', - key: 'instance', - params: { - query: 'i-16fd8d2a', - }, - }, - query: { - match_phrase: { - instance: 'i-16fd8d2a', - }, - }, - - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: false, - disabled: false, - type: 'exists', - key: 'instance', - value: 'exists', - }, - exists: { - field: 'instance', - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - { - meta: { - index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', - alias: null, - negate: false, - disabled: true, - type: 'exists', - key: 'instance', - value: 'exists', - }, - exists: { - field: 'region', - }, - $state: { - // @ts-ignore - store: 'appState', - }, - }, - ], - { - language: 'kuery', - query: 'instance : "i-088147ac"', - } - ) - ).toEqual({ - bool: { - must: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - instance: 'i-088147ac', - }, - }, - ], - }, - }, - { - bool: { - should: [ - { - match_phrase: { - instance: 'i-20d061fa', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - exists: { - field: 'instance', - }, - }, - ], - must_not: [ - { - match_phrase: { - instance: 'i-16fd8d2a', - }, - }, - ], - }, - }); - }); -}); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index fa0cccda99d22..4574c7e859c08 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -7,13 +7,11 @@ import { useEffect, useMemo, useState } from 'react'; import { combineLatest, from, Observable, of, Subject } from 'rxjs'; -import { isEqual } from 'lodash'; import { catchError, debounceTime, distinctUntilChanged, map, - pluck, skipWhile, startWith, switchMap, @@ -28,39 +26,22 @@ import { SWIMLANE_TYPE, SwimlaneType, } from '../../application/explorer/explorer_constants'; -import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; -import { Query } from '../../../../../../src/plugins/data/common/query'; -import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { ExplorerJob, OverallSwimlaneData } from '../../application/explorer/explorer_utils'; import { parseInterval } from '../../../common/util/parse_interval'; -import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; -import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../ui_actions/constants'; import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, } from '..'; +import { processFilters } from '../common/process_filters'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../..'; +import { getJobsObservable } from '../common/get_jobs_observable'; const FETCH_RESULTS_DEBOUNCE_MS = 500; -function getJobsObservable( - embeddableInput: Observable, - anomalyDetectorService: AnomalyDetectorService, - setErrorHandler: (e: Error) => void -) { - return embeddableInput.pipe( - pluck('jobIds'), - distinctUntilChanged(isEqual), - switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), - catchError((e) => { - setErrorHandler(e.body ?? e); - return of(undefined); - }) - ); -} - export function useSwimlaneInputResolver( embeddableInput: Observable, onInputChange: (output: Partial) => void, @@ -149,7 +130,7 @@ export function useSwimlaneInputResolver( let appliedFilters: any; try { - appliedFilters = processFilters(filters, query); + appliedFilters = processFilters(filters, query, CONTROLLED_BY_SWIM_LANE_FILTER); } catch (e) { // handle query syntax errors setError(e); @@ -242,44 +223,3 @@ export function useSwimlaneInputResolver( error, ]; } - -export function processFilters(filters: Filter[], query: Query) { - const inputQuery = - query.language === 'kuery' - ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)) - : query.query; - - const must = [inputQuery]; - const mustNot = []; - for (const filter of filters) { - // ignore disabled filters as well as created by swim lane selection - if (filter.meta.disabled || filter.meta.controlledBy === CONTROLLED_BY_SWIM_LANE_FILTER) - continue; - - const { - meta: { negate, type, key: fieldName }, - } = filter; - - let filterQuery = filter.query; - - if (filterQuery === undefined && type === 'exists') { - filterQuery = { - exists: { - field: fieldName, - }, - }; - } - - if (negate) { - mustNot.push(filterQuery); - } else { - must.push(filterQuery); - } - } - return { - bool: { - must, - must_not: mustNot, - }, - }; -} diff --git a/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx b/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx new file mode 100644 index 0000000000000..01644efd6652c --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/components/embeddable_loading_fallback.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +export const EmbeddableLoading = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts new file mode 100644 index 0000000000000..6bdec30340b76 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/get_jobs_observable.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, pluck, switchMap } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import { AnomalyChartsEmbeddableInput, AnomalySwimlaneEmbeddableInput } from '../types'; +import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; + +export function getJobsObservable( + embeddableInput: Observable, + anomalyDetectorService: AnomalyDetectorService, + setErrorHandler: (e: Error) => void +) { + return embeddableInput.pipe( + pluck('jobIds'), + distinctUntilChanged(isEqual), + switchMap((jobsIds) => anomalyDetectorService.getJobs$(jobsIds)), + catchError((e) => { + setErrorHandler(e.body ?? e); + return of(undefined); + }) + ); +} diff --git a/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts b/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts new file mode 100644 index 0000000000000..262b744786d97 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/process_filters.test.ts @@ -0,0 +1,288 @@ +/* + * 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 { processFilters } from './process_filters'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../..'; + +describe('processFilters', () => { + test('should format kql embeddable input to es query', () => { + expect( + processFilters( + [ + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + type: 'phrases', + key: 'instance', + value: 'i-20d061fa', + params: ['i-20d061fa'], + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'instance', + params: { + query: 'i-16fd8d2a', + }, + }, + query: { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'instance', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: true, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'region', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + ], + { + language: 'kuery', + query: 'instance : "i-088147ac"', + }, + CONTROLLED_BY_SWIM_LANE_FILTER + ) + ).toEqual({ + bool: { + must: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + instance: 'i-088147ac', + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + exists: { + field: 'instance', + }, + }, + ], + must_not: [ + { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + ], + }, + }); + }); + + test('should format lucene embeddable input to es query', () => { + expect( + processFilters( + [ + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + type: 'phrases', + key: 'instance', + value: 'i-20d061fa', + params: ['i-20d061fa'], + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: true, + disabled: false, + type: 'phrase', + key: 'instance', + params: { + query: 'i-16fd8d2a', + }, + }, + query: { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: false, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'instance', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + { + meta: { + index: 'c01fcbd0-8936-11ea-a70f-9f68bc175114', + alias: null, + negate: false, + disabled: true, + type: 'exists', + key: 'instance', + value: 'exists', + }, + exists: { + field: 'region', + }, + $state: { + // @ts-ignore + store: 'appState', + }, + }, + ], + { + language: 'lucene', + query: 'instance:i-d**', + }, + CONTROLLED_BY_SWIM_LANE_FILTER + ) + ).toEqual({ + bool: { + must: [ + { + query_string: { + query: 'instance:i-d**', + }, + }, + { + bool: { + should: [ + { + match_phrase: { + instance: 'i-20d061fa', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + exists: { + field: 'instance', + }, + }, + ], + must_not: [ + { + match_phrase: { + instance: 'i-16fd8d2a', + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/ml/public/embeddables/common/process_filters.ts b/x-pack/plugins/ml/public/embeddables/common/process_filters.ts new file mode 100644 index 0000000000000..8ff75205b4d48 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/process_filters.ts @@ -0,0 +1,55 @@ +/* + * 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 { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; +import { Query } from '../../../../../../src/plugins/data/common/query'; +import { esKuery, esQuery } from '../../../../../../src/plugins/data/public'; + +export function processFilters(filters: Filter[], query: Query, controlledBy?: string) { + const inputQuery = + query.language === 'kuery' + ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)) + : esQuery.luceneStringToDsl(query.query); + + const must = [inputQuery]; + const mustNot = []; + + for (const filter of filters) { + // ignore disabled filters as well as created by swim lane selection + if ( + filter.meta.disabled || + (controlledBy !== undefined && filter.meta.controlledBy === controlledBy) + ) + continue; + + const { + meta: { negate, type, key: fieldName }, + } = filter; + + let filterQuery = filter.query; + + if (filterQuery === undefined && type === 'exists') { + filterQuery = { + exists: { + field: fieldName, + }, + }; + } + + if (negate) { + mustNot.push(filterQuery); + } else { + must.push(filterQuery); + } + } + return { + bool: { + must, + must_not: mustNot, + }, + }; +} diff --git a/x-pack/plugins/ml/public/embeddables/constants.ts b/x-pack/plugins/ml/public/embeddables/constants.ts index c50264ccccd97..8307eeda23ec6 100644 --- a/x-pack/plugins/ml/public/embeddables/constants.ts +++ b/x-pack/plugins/ml/public/embeddables/constants.ts @@ -6,3 +6,4 @@ */ export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; +export const ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE = 'ml_anomaly_charts'; diff --git a/x-pack/plugins/ml/public/embeddables/index.ts b/x-pack/plugins/ml/public/embeddables/index.ts index b1dd3ac0a4a17..2011d7217094b 100644 --- a/x-pack/plugins/ml/public/embeddables/index.ts +++ b/x-pack/plugins/ml/public/embeddables/index.ts @@ -8,6 +8,7 @@ import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane'; import type { MlCoreSetup } from '../plugin'; import type { EmbeddableSetup } from '../../../../../src/plugins/embeddable/public'; +import { AnomalyChartsEmbeddableFactory } from './anomaly_charts'; export * from './constants'; export * from './types'; @@ -20,4 +21,8 @@ export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSet anomalySwimlaneEmbeddableFactory.type, anomalySwimlaneEmbeddableFactory ); + + const anomalyChartsFactory = new AnomalyChartsEmbeddableFactory(core.getStartServices); + + embeddable.registerEmbeddableFactory(anomalyChartsFactory.type, anomalyChartsFactory); } diff --git a/x-pack/plugins/ml/public/embeddables/types.ts b/x-pack/plugins/ml/public/embeddables/types.ts index 712aba707f4c7..05aea1770a415 100644 --- a/x-pack/plugins/ml/public/embeddables/types.ts +++ b/x-pack/plugins/ml/public/embeddables/types.ts @@ -23,6 +23,15 @@ import type { AnomalyDetectorService } from '../application/services/anomaly_det import type { AnomalyTimelineService } from '../application/services/anomaly_timeline_service'; import type { MlDependencies } from '../application/app'; import type { AppStateSelectedCells } from '../application/explorer/explorer_utils'; +import { AnomalyExplorerChartsService } from '../application/services/anomaly_explorer_charts_service'; +import { EntityField } from '../../common/util/anomaly_utils'; +import { isPopulatedObject } from '../../common/util/object_utils'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, +} from './constants'; +import { MlResultsService } from '../application/services/results_service'; +import { IndexPattern } from '../../../../../src/plugins/data/common/index_patterns/index_patterns'; export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; @@ -69,3 +78,60 @@ export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { */ data?: AppStateSelectedCells; } + +export function isSwimLaneEmbeddable(arg: unknown): arg is SwimLaneDrilldownContext { + return ( + isPopulatedObject(arg) && + arg.hasOwnProperty('embeddable') && + arg.embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE + ); +} + +/** + * Anomaly Explorer + */ +export interface AnomalyChartsEmbeddableCustomInput { + jobIds: JobId[]; + maxSeriesToPlot: number; + + // Embeddable inputs which are not included in the default interface + filters: Filter[]; + query: Query; + refreshConfig: RefreshInterval; + timeRange: TimeRange; + severityThreshold?: number; +} + +export type AnomalyChartsEmbeddableInput = EmbeddableInput & AnomalyChartsEmbeddableCustomInput; + +export interface AnomalyChartsServices { + anomalyDetectorService: AnomalyDetectorService; + anomalyExplorerService: AnomalyExplorerChartsService; + mlResultsService: MlResultsService; +} + +export type AnomalyChartsEmbeddableServices = [CoreStart, MlDependencies, AnomalyChartsServices]; +export interface AnomalyChartsCustomOutput { + entityFields?: EntityField[]; + severity?: number; + indexPatterns?: IndexPattern[]; +} +export type AnomalyChartsEmbeddableOutput = EmbeddableOutput & AnomalyChartsCustomOutput; +export interface EditAnomalyChartsPanelContext { + embeddable: IEmbeddable; +} +export interface AnomalyChartsFieldSelectionContext extends EditAnomalyChartsPanelContext { + /** + * Optional fields selected using anomaly charts + */ + data?: EntityField[]; +} +export function isAnomalyExplorerEmbeddable( + arg: unknown +): arg is AnomalyChartsFieldSelectionContext { + return ( + isPopulatedObject(arg) && + arg.hasOwnProperty('embeddable') && + arg.embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE + ); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx new file mode 100644 index 0000000000000..03b6459f82f58 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_entity_filters_action.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { MlCoreSetup } from '../plugin'; +import { Filter, FilterStateStore } from '../../../../../src/plugins/data/common'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + AnomalyChartsFieldSelectionContext, +} from '../embeddables'; +import { CONTROLLED_BY_ANOMALY_CHARTS_FILTER } from './constants'; +import { ENTITY_FIELD_OPERATIONS } from '../../common/util/anomaly_utils'; + +export const APPLY_ENTITY_FIELD_FILTERS_ACTION = 'applyEntityFieldFiltersAction'; + +export function createApplyEntityFieldFiltersAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-entity-field-filters', + type: APPLY_ENTITY_FIELD_FILTERS_ACTION, + getIconType(context: AnomalyChartsFieldSelectionContext): string { + return 'filter'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.applyEntityFieldsFiltersTitle', { + defaultMessage: 'Filter for value', + }); + }, + async execute({ data }) { + if (!data) { + throw new Error('No entities provided'); + } + const [, pluginStart] = await getStartServices(); + const filterManager = pluginStart.data.query.filterManager; + + filterManager.addFilters( + data + .filter((d) => d.operation === ENTITY_FIELD_OPERATIONS.ADD) + .map(({ fieldName, fieldValue }) => { + return { + $state: { + store: FilterStateStore.APP_STATE, + }, + meta: { + alias: i18n.translate('xpack.ml.actions.entityFieldFilterAliasLabel', { + defaultMessage: '{labelValue}', + values: { + labelValue: `${fieldName}:${fieldValue}`, + }, + }), + controlledBy: CONTROLLED_BY_ANOMALY_CHARTS_FILTER, + negate: false, + disabled: false, + type: 'phrase', + key: fieldName, + params: { + query: fieldValue, + }, + }, + query: { + match_phrase: { + [fieldName]: fieldValue, + }, + }, + }; + }) + ); + + data + .filter((field) => field.operation === ENTITY_FIELD_OPERATIONS.REMOVE) + .forEach((field) => { + const filter = filterManager + .getFilters() + .find( + (f) => f.meta.key === field.fieldName && f.meta.params.query === field.fieldValue + ); + if (filter) { + filterManager.removeFilter(filter); + } + }); + }, + async isCompatible({ embeddable, data }) { + return embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE && data !== undefined; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx index e3d2ca4ce0de1..0642687e2926c 100644 --- a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -44,7 +44,7 @@ export function createApplyInfluencerFiltersAction( }, meta: { alias: i18n.translate('xpack.ml.actions.influencerFilterAliasLabel', { - defaultMessage: 'Influencer {labelValue}', + defaultMessage: '{labelValue}', values: { labelValue: `${data.viewByFieldName}:${influencerValue}`, }, diff --git a/x-pack/plugins/ml/public/ui_actions/constants.ts b/x-pack/plugins/ml/public/ui_actions/constants.ts index 6dc3f03d10fd9..459f342dc4527 100644 --- a/x-pack/plugins/ml/public/ui_actions/constants.ts +++ b/x-pack/plugins/ml/public/ui_actions/constants.ts @@ -6,3 +6,4 @@ */ export const CONTROLLED_BY_SWIM_LANE_FILTER = 'anomaly-swim-lane'; +export const CONTROLLED_BY_ANOMALY_CHARTS_FILTER = 'anomaly-charts'; diff --git a/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx new file mode 100644 index 0000000000000..1895ed3acf981 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/edit_anomaly_charts_panel_action.tsx @@ -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 { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { MlCoreSetup } from '../plugin'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + EditAnomalyChartsPanelContext, +} from '../embeddables'; + +export const EDIT_ANOMALY_CHARTS_PANEL_ACTION = 'editAnomalyChartsPanelAction'; + +export function createEditAnomalyChartsPanelAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'edit-anomaly-charts', + type: EDIT_ANOMALY_CHARTS_PANEL_ACTION, + getIconType(context): string { + return 'pencil'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.editAnomalyChartsTitle', { + defaultMessage: 'Edit anomaly charts', + }), + async execute({ embeddable }) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + + const [coreStart] = await getStartServices(); + + try { + const { resolveEmbeddableAnomalyChartsUserInput } = await import( + '../embeddables/anomaly_charts/anomaly_charts_setup_flyout' + ); + + const result = await resolveEmbeddableAnomalyChartsUserInput( + coreStart, + embeddable.getInput() + ); + embeddable.updateInput(result); + } catch (e) { + return Promise.reject(); + } + }, + async isCompatible({ embeddable }) { + return ( + embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE && + embeddable.getInput().viewMode === ViewMode.EDIT + ); + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 6fec66382e3f9..46e928e5f55eb 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -12,17 +12,21 @@ import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; import { createApplyInfluencerFiltersAction } from './apply_influencer_filters_action'; -import { SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger } from './triggers'; +import { + entityFieldSelectionTrigger, + EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, + SWIM_LANE_SELECTION_TRIGGER, + swimLaneSelectionTrigger, +} from './triggers'; import { createApplyTimeRangeSelectionAction } from './apply_time_range_action'; import { createClearSelectionAction } from './clear_selection_action'; - +import { createEditAnomalyChartsPanelAction } from './edit_anomaly_charts_panel_action'; +import { createApplyEntityFieldFiltersAction } from './apply_entity_filters_action'; export { APPLY_TIME_RANGE_SELECTION_ACTION } from './apply_time_range_action'; export { EDIT_SWIMLANE_PANEL_ACTION } from './edit_swimlane_panel_action'; export { APPLY_INFLUENCER_FILTERS_ACTION } from './apply_influencer_filters_action'; export { OPEN_IN_ANOMALY_EXPLORER_ACTION } from './open_in_anomaly_explorer_action'; - -export { SWIM_LANE_SELECTION_TRIGGER } from './triggers'; - +export { SWIM_LANE_SELECTION_TRIGGER }; /** * Register ML UI actions */ @@ -34,24 +38,31 @@ export function registerMlUiActions( const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); + const applyEntityFieldFilterAction = createApplyEntityFieldFiltersAction(core.getStartServices); const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); const clearSelectionAction = createClearSelectionAction(core.getStartServices); + const editExplorerPanelAction = createEditAnomalyChartsPanelAction(core.getStartServices); // Register actions uiActions.registerAction(editSwimlanePanelAction); uiActions.registerAction(openInExplorerAction); uiActions.registerAction(applyInfluencerFiltersAction); + uiActions.registerAction(applyEntityFieldFilterAction); uiActions.registerAction(applyTimeRangeSelectionAction); uiActions.registerAction(clearSelectionAction); + uiActions.registerAction(editExplorerPanelAction); // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, editExplorerPanelAction.id); uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInExplorerAction.id); uiActions.registerTrigger(swimLaneSelectionTrigger); + uiActions.registerTrigger(entityFieldSelectionTrigger); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, clearSelectionAction); + uiActions.addTriggerAction(EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, applyEntityFieldFilterAction); } diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx index 614df96b59963..7353502f95b47 100644 --- a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx @@ -9,12 +9,21 @@ import { i18n } from '@kbn/i18n'; import { createAction } from '../../../../../src/plugins/ui_actions/public'; import { MlCoreSetup } from '../plugin'; import { ML_APP_URL_GENERATOR } from '../../common/constants/ml_url_generator'; -import { ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, SwimLaneDrilldownContext } from '../embeddables'; +import { + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + AnomalyChartsFieldSelectionContext, + isAnomalyExplorerEmbeddable, + isSwimLaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables'; +import { ENTITY_FIELD_OPERATIONS } from '../../common/util/anomaly_utils'; +import { ExplorerAppState } from '../../common/types/ml_url_generator'; export const OPEN_IN_ANOMALY_EXPLORER_ACTION = 'openInAnomalyExplorerAction'; export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getStartServices']) { - return createAction({ + return createAction({ id: 'open-in-anomaly-explorer', type: OPEN_IN_ANOMALY_EXPLORER_ACTION, getIconType(context): string { @@ -25,42 +34,98 @@ export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getSta defaultMessage: 'Open in Anomaly Explorer', }); }, - async getHref({ embeddable, data }): Promise { + async getHref(context): Promise { const [, pluginsStart] = await getStartServices(); const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); - const { jobIds, timeRange, viewBy } = embeddable.getInput(); - const { perPage, fromPage } = embeddable.getOutput(); - return urlGenerator.createUrl({ - page: 'explorer', - pageState: { - jobIds, - timeRange, - mlExplorerSwimlane: { - viewByFromPage: fromPage, - viewByPerPage: perPage, - viewByFieldName: viewBy, - ...(data - ? { - selectedType: data.type, - selectedTimes: data.times, - selectedLanes: data.lanes, - } - : {}), + if (isSwimLaneEmbeddable(context)) { + const { embeddable, data } = context; + + const { jobIds, timeRange, viewBy } = embeddable.getInput(); + const { perPage, fromPage } = embeddable.getOutput(); + + return urlGenerator.createUrl({ + page: 'explorer', + pageState: { + jobIds, + timeRange, + mlExplorerSwimlane: { + viewByFromPage: fromPage, + viewByPerPage: perPage, + viewByFieldName: viewBy, + ...(data + ? { + selectedType: data.type, + selectedTimes: data.times, + selectedLanes: data.lanes, + } + : {}), + }, }, - }, - }); + }); + } else if (isAnomalyExplorerEmbeddable(context)) { + const { embeddable } = context; + + const { jobIds, timeRange } = embeddable.getInput(); + const { entityFields } = embeddable.getOutput(); + + let mlExplorerFilter: ExplorerAppState['mlExplorerFilter'] | undefined; + if ( + Array.isArray(entityFields) && + entityFields.length === 1 && + entityFields[0].operation === ENTITY_FIELD_OPERATIONS.ADD + ) { + const { fieldName, fieldValue } = entityFields[0]; + if (fieldName !== undefined && fieldValue !== undefined) { + const influencersFilterQuery = { + bool: { + should: [ + { + match_phrase: { + [fieldName]: fieldValue, + }, + }, + ], + minimum_should_match: 1, + }, + }; + const filteredFields = [fieldName, fieldValue]; + mlExplorerFilter = { + influencersFilterQuery, + filterActive: true, + queryString: `${fieldName}:"${fieldValue}"`, + ...(Array.isArray(filteredFields) ? { filteredFields } : {}), + }; + } + } + return urlGenerator.createUrl({ + page: 'explorer', + pageState: { + jobIds, + timeRange, + ...(mlExplorerFilter ? { mlExplorerFilter } : {}), + query: {}, + }, + }); + } }, - async execute({ embeddable, data }) { - if (!embeddable) { + async execute(context) { + if (!context.embeddable) { throw new Error('Not possible to execute an action without the embeddable context'); } const [{ application }] = await getStartServices(); - const anomalyExplorerUrl = await this.getHref!({ embeddable, data }); - await application.navigateToUrl(anomalyExplorerUrl!); + const anomalyExplorerUrl = await this.getHref!(context); + if (anomalyExplorerUrl) { + await application.navigateToUrl(anomalyExplorerUrl!); + } }, - async isCompatible({ embeddable }: SwimLaneDrilldownContext) { - return embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; + async isCompatible({ + embeddable, + }: SwimLaneDrilldownContext | AnomalyChartsFieldSelectionContext) { + return ( + embeddable.type === ANOMALY_SWIMLANE_EMBEDDABLE_TYPE || + embeddable.type === ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE + ); }, }); } diff --git a/x-pack/plugins/ml/public/ui_actions/triggers.ts b/x-pack/plugins/ml/public/ui_actions/triggers.ts index 7763facbdc158..05074ce6df271 100644 --- a/x-pack/plugins/ml/public/ui_actions/triggers.ts +++ b/x-pack/plugins/ml/public/ui_actions/triggers.ts @@ -16,3 +16,12 @@ export const swimLaneSelectionTrigger: Trigger = { title: '', description: 'Swim lane selection triggered', }; + +export const EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER = 'EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER'; +export const entityFieldSelectionTrigger: Trigger = { + id: EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER, + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', + description: 'Entity field selection triggered', +}; diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts index 3a30a141f88f5..f94aaffc41a7b 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_extractor.ts @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line import/no-extraneous-dependencies import * as ts from 'typescript'; export interface DocEntry { diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index b950b064774b1..a597754d6c409 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -27,7 +27,6 @@ import { ALERT_THREAD_POOL_WRITE_REJECTIONS, ALERT_DETAILS, } from '../common/constants'; - import { createCpuUsageAlertType } from './alerts/cpu_usage_alert'; import { createMissingMonitoringDataAlertType } from './alerts/missing_monitoring_data_alert'; import { createLegacyAlertTypes } from './alerts/legacy_alert'; @@ -44,6 +43,8 @@ interface MonitoringSetupPluginDependencies { usageCollection: UsageCollectionSetup; } +const HASH_CHANGE = 'hashchange'; + export class MonitoringPlugin implements Plugin { @@ -106,7 +107,6 @@ export class MonitoringPlugin usageCollection: plugins.usageCollection, }; - this.setInitialTimefilter(deps); const monitoringApp = new AngularApp(deps); const removeHistoryListener = params.history.listen((location) => { if (location.pathname === '' && location.hash === '') { @@ -114,7 +114,11 @@ export class MonitoringPlugin } }); + const removeHashChange = this.setInitialTimefilter(deps); return () => { + if (removeHashChange) { + removeHashChange(); + } removeHistoryListener(); monitoringApp.destroy(); }; @@ -131,8 +135,24 @@ export class MonitoringPlugin private setInitialTimefilter({ data }: MonitoringStartPluginDependencies) { const { timefilter } = data.query.timefilter; - const refreshInterval = { value: 10000, pause: false }; - timefilter.setRefreshInterval(refreshInterval); + const { pause: pauseByDefault } = timefilter.getRefreshIntervalDefaults(); + if (pauseByDefault) { + return; + } + /** + * We can't use timefilter.getRefreshIntervalUpdate$ last value, + * since it's not a BehaviorSubject. This means we need to wait for + * hash change because of angular's applyAsync + */ + const onHashChange = () => { + const { value, pause } = timefilter.getRefreshInterval(); + if (!value && pause) { + window.removeEventListener(HASH_CHANGE, onHashChange); + timefilter.setRefreshInterval({ value: 10000, pause: false }); + } + }; + window.addEventListener(HASH_CHANGE, onHashChange, false); + return () => window.removeEventListener(HASH_CHANGE, onHashChange); } private getExternalConfig() { diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 05abac80b67ce..cb6ea799078a2 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -6,3 +6,4 @@ */ export const enableAlertingExperience = 'observability:enableAlertingExperience'; +export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index f473ed963c75a..35443ca090077 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -19,6 +19,7 @@ export type { ObservabilityPublicPluginsSetup, ObservabilityPublicPluginsStart, }; +export { enableInspectEsQueries } from '../common/ui_settings_keys'; export const plugin: PluginInitializer< ObservabilityPublicSetup, diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 3123ce96114d7..43041280d0282 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { UiSettingsParams } from '../../../../src/core/types'; -import { enableAlertingExperience } from '../common/ui_settings_keys'; +import { enableAlertingExperience, enableInspectEsQueries } from '../common/ui_settings_keys'; /** * uiSettings definitions for Observability. @@ -29,4 +29,15 @@ export const uiSettings: Record> = { ), schema: schema.boolean(), }, + [enableInspectEsQueries]: { + category: ['observability'], + name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', { + defaultMessage: 'inspect ES queries', + }), + value: false, + description: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentDescription', { + defaultMessage: 'Inspect Elasticsearch queries in API responses.', + }), + schema: schema.boolean(), + }, }; diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx index f16655f048ed6..9767ee90fc14c 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor/runtime_field_editor.test.tsx @@ -24,7 +24,10 @@ const setup = (props?: Props) => const docLinks: DocLinksStart = { ELASTIC_WEBSITE_URL: 'https://jestTest.elastic.co', DOC_LINK_VERSION: 'jest', - links: {} as any, + links: { + runtimeFields: { mapping: 'https://jestTest.elastic.co/to-be-defined.html' }, + scriptedFields: {} as any, + } as any, }; describe('Runtime field editor', () => { diff --git a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx index f8f276f1754ac..abcff4a79a475 100644 --- a/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx +++ b/x-pack/plugins/runtime_fields/public/components/runtime_field_editor_flyout_content/runtime_field_editor_flyout_content.test.tsx @@ -23,7 +23,10 @@ const setup = (props?: Props) => const docLinks: DocLinksStart = { ELASTIC_WEBSITE_URL: 'htts://jestTest.elastic.co', DOC_LINK_VERSION: 'jest', - links: {} as any, + links: { + runtimeFields: { mapping: 'https://jestTest.elastic.co/to-be-defined.html' }, + scriptedFields: {} as any, + } as any, }; const noop = () => {}; diff --git a/x-pack/plugins/runtime_fields/public/lib/documentation.ts b/x-pack/plugins/runtime_fields/public/lib/documentation.ts index 14f3e825d14ab..dfdd50b07d769 100644 --- a/x-pack/plugins/runtime_fields/public/lib/documentation.ts +++ b/x-pack/plugins/runtime_fields/public/lib/documentation.ts @@ -7,14 +7,11 @@ import { DocLinksStart } from 'src/core/public'; -export const getLinks = (docLinks: DocLinksStart) => { - const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; - const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; - const esDocsBase = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; - const painlessDocsBase = `${docsBase}/elasticsearch/painless/${DOC_LINK_VERSION}`; - +export const getLinks = ({ links }: DocLinksStart) => { + const runtimePainless = `${links.runtimeFields.mapping}`; + const painlessSyntax = `${links.scriptedFields.painlessLangSpec}`; return { - runtimePainless: `${esDocsBase}/runtime.html#runtime-mapping-fields`, - painlessSyntax: `${painlessDocsBase}/painless-lang-spec.html`, + runtimePainless, + painlessSyntax, }; }; diff --git a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts index 29f1c2555e030..c96b1e888ff9c 100644 --- a/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts +++ b/x-pack/plugins/security/public/security_checkup/security_checkup_service.test.ts @@ -73,7 +73,7 @@ describe('SecurityCheckupService', () => { ?.getAttribute('href'); expect(docLink).toMatchInlineSnapshot( - `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/get-started-enable-security.html?blade=kibanasecuritymessage"` + `"https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/configuring-stack-security.html?blade=kibanasecuritymessage"` ); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 8234c3a9a599d..70fe2b6187aa6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -22,7 +22,7 @@ import { Query, Language, Index, TimestampOverrideOrUndefined } from './schemas/ export const getQueryFilter = ( query: Query, language: Language, - filters: Array>, + filters: unknown, index: Index, lists: Array, excludeExceptions: boolean = true @@ -48,7 +48,7 @@ export const getQueryFilter = ( chunkSize: 1024, }); const initialQuery = { query, language }; - const allFilters = getAllFilters((filters as unknown) as Filter[], exceptionFilter); + const allFilters = getAllFilters(filters as Filter[], exceptionFilter); return buildEsQuery(indexPattern, initialQuery, allFilters, config); }; diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index cf2b234451f50..b35504fc88659 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -7,6 +7,7 @@ import { Client } from '@elastic/elasticsearch'; import seedrandom from 'seedrandom'; +// eslint-disable-next-line import/no-extraneous-dependencies import { KbnClient } from '@kbn/test'; import { AxiosResponse } from 'axios'; import { EndpointDocGenerator, TreeOptions, Event } from './generate_data'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 0b41dc5608fe9..bed9c2880440a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -380,12 +380,12 @@ export enum HostStatus { * Default state of the host when no host information is present or host information cannot * be retrieved. e.g. API error */ - ERROR = 'error', + UNHEALTHY = 'unhealthy', /** * Host is online as indicated by its checkin status during the last checkin window */ - ONLINE = 'online', + HEALTHY = 'healthy', /** * Host is offline as indicated by its checkin status during the last checkin window @@ -393,9 +393,14 @@ export enum HostStatus { OFFLINE = 'offline', /** - * Host is unenrolling as indicated by its checkin status during the last checkin window + * Host is unenrolling, enrolling or updating as indicated by its checkin status during the last checkin window */ - UNENROLLING = 'unenrolling', + UPDATING = 'updating', + + /** + * Host is inactive as indicated by its checkin status during the last checkin window + */ + INACTIVE = 'inactive', } export enum MetadataQueryStrategyVersions { diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index 22e4179ae7050..79a0351b824e8 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -26,6 +26,19 @@ export const validate = ( return pipe(checked, fold(left, right)); }; +export const validateNonExact = ( + obj: object, + schema: T +): [t.TypeOf | null, string | null] => { + const decoded = schema.decode(obj); + const left = (errors: t.Errors): [T | null, string | null] => [ + null, + formatErrors(errors).join(','), + ]; + const right = (output: T): [T | null, string | null] => [output, null]; + return pipe(decoded, fold(left, right)); +}; + export const validateEither = ( schema: T, obj: A diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts index 2e0599dfcae21..dee921b0c668a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_modal.spec.ts @@ -133,7 +133,7 @@ describe('Exceptions modal', () => { closeExceptionBuilderModal(); }); - it.skip('Does not overwrite values of nested entry items', () => { + it('Does not overwrite values of nested entry items', () => { openExceptionModalFromRuleSettings(); cy.get(LOADING_SPINNER).should('not.exist'); @@ -144,13 +144,14 @@ describe('Exceptions modal', () => { // exception item 2 with nested field cy.get(ADD_OR_BTN).click(); - addExceptionEntryFieldValueOfItemX('c', 1, 0); + addExceptionEntryFieldValueOfItemX('agent.name', 1, 0); cy.get(ADD_NESTED_BTN).click(); addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1); cy.get(ADD_AND_BTN).click(); addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3); // This button will now read `Add non-nested button` - cy.get(ADD_NESTED_BTN).click(); + cy.get(ADD_NESTED_BTN).scrollIntoView(); + cy.get(ADD_NESTED_BTN).focus().click(); addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4); // should have only deleted `user.id` @@ -161,7 +162,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); cy.get(EXCEPTION_ITEM_CONTAINER) @@ -178,7 +183,11 @@ describe('Exceptions modal', () => { .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT) + .eq(0) + .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) .find(FIELD_INPUT) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index b0ffcb8c5b5b8..7e9e7c40258da 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -115,7 +115,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ onRuleChange, alertStatus, }: AddExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [errorsExist, setErrorExists] = useState(false); const [comment, setComment] = useState(''); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -394,6 +394,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} ({ v4: jest.fn().mockReturnValue('123'), })); -const getEntryNestedWithIdMock = () => ({ - id: '123', - ...getEntryNestedMock(), -}); - -const getEntryExistsWithIdMock = () => ({ - id: '123', - ...getEntryExistsMock(), -}); - -const getEntryMatchWithIdMock = () => ({ - id: '123', - ...getEntryMatchMock(), -}); - -const getEntryMatchAnyWithIdMock = () => ({ - id: '123', - ...getEntryMatchAnyMock(), -}); - const getMockIndexPattern = (): IIndexPattern => ({ id: '1234', title: 'logstash-*', fields, }); -const getMockBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('ip'), - operator: isOperator, - value: 'some value', - nested: undefined, - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: getField('nestedField.child'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - -const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ - id: '123', - field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, - operator: isOperator, - value: undefined, - nested: 'parent', - parent: undefined, - entryIndex: 0, - correspondingKeywordField: undefined, -}); - const mockEndpointFields = [ { name: 'file.path.caseless', @@ -154,1254 +48,22 @@ export const getEndpointField = (name: string) => mockEndpointFields.find((field) => field.name === name) as IFieldType; describe('Exception builder helpers', () => { - describe('#getCorrespondingKeywordField', () => { - test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw.text', - }); + describe('#filterIndexPatterns', () => { + test('it returns index patterns without filtering if list type is "detection"', () => { + const mockIndexPatterns = getMockIndexPattern(); + const output = filterIndexPatterns(mockIndexPatterns, 'detection'); - expect(output).toEqual(getField('machine.os.raw')); + expect(output).toEqual(mockIndexPatterns); }); - test('it returns undefined if "selectedFieldIsTextType" is false', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: 'machine.os.raw', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is empty string', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: '', - }); - - expect(output).toEqual(undefined); - }); - - test('it returns undefined if "selectedField" is undefined', () => { - const output = getCorrespondingKeywordField({ - fields, - selectedField: undefined, - }); - - expect(output).toEqual(undefined); - }); - }); - - describe('#getFilteredIndexPatterns', () => { - describe('list type detections', () => { - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'child' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); - const expected: IIndexPattern = { - fields: [...fields], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - - describe('list type endpoint', () => { - let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - - beforeAll(() => { - payloadIndexPattern = { - ...payloadIndexPattern, - fields: [...payloadIndexPattern.fields, ...mockEndpointFields], - }; - }); - - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - id: '123', - field: getEndpointField('file.Ext.code_signature.status'), - operator: isOperator, - value: 'some value', - nested: 'child', - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'file.Ext.code_signature', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - entryIndex: 0, - correspondingKeywordField: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: 'status' }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: { - ...getEndpointField('file.Ext.code_signature.status'), - name: 'file.Ext.code_signature', - esTypes: ['nested'], - }, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['nested'], - name: 'file.Ext.code_signature', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'file.Ext.code_signature', - }, - }, - type: 'string', - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [getEndpointField('file.Ext.code_signature.status')], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - - test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); - const expected: IIndexPattern = { - fields: [ - { - aggregatable: false, - count: 0, - esTypes: ['keyword'], - name: 'file.path.caseless', - readFromDocValues: false, - scripted: false, - searchable: true, - type: 'string', - }, - { - name: 'file.Ext.code_signature.status', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: true, - aggregatable: false, - readFromDocValues: false, - subType: { nested: { path: 'file.Ext.code_signature' } }, - }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); - }); - }); - - describe('#getFormattedBuilderEntry', () => { - test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { - const payloadIndexPattern: IIndexPattern = { + test('it returns filtered index patterns if list type is "endpoint"', () => { + const mockIndexPatterns = { ...getMockIndexPattern(), - fields: [ - ...fields, - { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - ], - }; - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'machine.os.raw.text', - value: 'some os', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - name: 'machine.os.raw.text', - type: 'string', - esTypes: ['text'], - count: 0, - scripted: false, - searchable: false, - aggregatable: false, - readFromDocValues: true, - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some os', - correspondingKeywordField: getField('machine.os.raw'), - }; - expect(output).toEqual(expected); - }); - - test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' }; - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - payloadParent, - 1 - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [{ ...payloadItem }], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - - test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: BuilderEntry = { - ...getEntryMatchWithIdMock(), - field: 'ip', - value: 'some ip', - }; - const output = getFormattedBuilderEntry( - payloadIndexPattern, - payloadItem, - 0, - undefined, - undefined - ); - const expected: FormattedBuilderEntry = { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#isEntryNested', () => { - test('it returns "false" if payload is not of type EntryNested', () => { - const payload: BuilderEntry = getEntryMatchWithIdMock(); - const output = isEntryNested(payload); - const expected = false; - expect(output).toEqual(expected); - }); - - test('it returns "true if payload is of type EntryNested', () => { - const payload: EntryNested = getEntryNestedWithIdMock(); - const output = isEntryNested(payload); - const expected = true; - expect(output).toEqual(expected); - }); - }); - - describe('#getFormattedBuilderEntries', () => { - test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: undefined, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when no nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] }, - ]; - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: true, - count: 0, - esTypes: ['keyword'], - name: 'extension', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'string', - }, - nested: undefined, - operator: isOneOfOperator, - parent: undefined, - value: ['some extension'], - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - - test('it returns formatted entries when nested entries exist', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadParent: EntryNested = { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }], - }; - const payloadItems: BuilderEntry[] = [ - { ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' }, - { ...payloadParent }, - ]; - - const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); - const expected: FormattedBuilderEntry[] = [ - { - id: '123', - entryIndex: 0, - field: { - aggregatable: true, - count: 0, - esTypes: ['ip'], - name: 'ip', - readFromDocValues: true, - scripted: false, - searchable: true, - type: 'ip', - }, - nested: undefined, - operator: isOperator, - parent: undefined, - value: 'some ip', - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 1, - field: { - aggregatable: false, - esTypes: ['nested'], - name: 'nestedField', - searchable: false, - type: 'string', - }, - nested: 'parent', - operator: isOperator, - parent: undefined, - value: undefined, - correspondingKeywordField: undefined, - }, - { - id: '123', - entryIndex: 0, - field: { - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'child', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { - nested: { - path: 'nestedField', - }, - }, - type: 'string', - }, - nested: 'child', - operator: isOperator, - parent: { - parent: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some host name', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - parentIndex: 1, - }, - value: 'some host name', - correspondingKeywordField: undefined, - }, - ]; - expect(output).toEqual(expected); - }); - }); - - describe('#getUpdatedEntriesOnDelete', () => { - test('it removes entry corresponding to "entryIndex"', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: ENTRIES_WITH_IDS, - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - id: '123', - field: 'some.not.nested.field', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes nested entry of "entryIndex" with corresponding parent index', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] }, - ], - }; - expect(output).toEqual(expected); - }); - - test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { - const payloadItem: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [ - { - ...getEntryNestedWithIdMock(), - entries: [{ ...getEntryExistsWithIdMock() }], - }, - ], - }; - const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); - const expected: ExceptionsBuilderExceptionItem = { - ...getExceptionListItemSchemaMock(), - entries: [], - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryFromOperator', () => { - test('it returns current value when switching from "is" to "is not"', () => { - const payloadOperator: OperatorOption = isNotOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not" to "is"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'I should stay the same', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match"', () => { - const payloadOperator: OperatorOption = isOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatch & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is one of" to "is not one of"', () => { - const payloadOperator: OperatorOption = isNotOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "is not one of" to "is one of"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isNotOneOfOperator, - value: ['I should stay the same'], - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['I should stay the same'], - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "match_any"', () => { - const payloadOperator: OperatorOption = isOneOfOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryMatchAny & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], + fields: [...fields, ...mockEndpointFields], }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "exists" to "does not exist"', () => { - const payloadOperator: OperatorOption = doesNotExistOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: existsOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: 'excluded', - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns current value when switching from "does not exist" to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: doesNotExistOperator, - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "exists"', () => { - const payloadOperator: OperatorOption = existsOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryExists & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'exists', - }; - expect(output).toEqual(expected); - }); - - test('it returns empty value when switching operator types to "list"', () => { - const payloadOperator: OperatorOption = isInListOperator; - const payloadEntry: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOperator, - value: 'I should stay the same', - }; - const output = getEntryFromOperator(payloadOperator, payloadEntry); - const expected: EntryList & { id?: string } = { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: 'list', - list: { id: '', type: 'ip' }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getOperatorOptions', () => { - test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" if no field selected', () => { - const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator]; - expect(output).toEqual(expected); - }); + const output = filterIndexPatterns(mockIndexPatterns, 'endpoint'); - test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'endpoint', true); - const expected: OperatorOption[] = [isOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [isOperator, existsOperator]; - expect(output).toEqual(expected); - }); - - test('it returns all operator options if "listType" is "detection"', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false); - const expected: OperatorOption[] = EXCEPTION_OPERATORS; - expect(output).toEqual(expected); - }); - - test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', true); - const expected: OperatorOption[] = [ - isOperator, - isNotOperator, - existsOperator, - doesNotExistOperator, - ]; - expect(output).toEqual(expected); - }); - - test('it returns list operators if specified to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, true); - expect(output).toEqual(EXCEPTION_OPERATORS); - }); - - test('it does not return list operators if specified not to', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getOperatorOptions(payloadItem, 'detection', false, false); - expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); - }); - }); - - describe('#getEntryOnFieldChange', () => { - test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [ - { ...getEntryMatchWithIdMock(), field: 'child' }, - getEntryMatchAnyWithIdMock(), - ], - }, - parentIndex: 0, - }, - }; - const payloadIFieldType: IFieldType = getField('nestedField.child'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - getEntryMatchAnyWithIdMock(), - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns field of type "match" with updated field if not a nested entry', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadIFieldType: IFieldType = getField('ip'); - const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - field: 'ip', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: '', - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnOperatorChange', () => { - test('it returns updated subentry preserving its value when entry is not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'some value', - operator: 'excluded', - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: [], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isNotOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.EXCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'some value', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const payloadOperator: OperatorOption = isOneOfOperator; - const output = getEntryOnOperatorChange(payloadItem, payloadOperator); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: [], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; - const output = getEntryOnMatchChange(payload, 'jibber jabber'); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH, - value: 'jibber jabber', - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnMatchAnyChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: ['some value'], - field: undefined, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: 'child', - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - operator: OperatorEnum.INCLUDED, - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - - test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockNestedBuilderEntry(), - field: undefined, - parent: { - parent: { - ...getEntryNestedWithIdMock(), - field: 'nestedField', - entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }], - }, - parentIndex: 0, - }, - }; - const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - index: 0, - updatedEntry: { - id: '123', - entries: [ - { - id: '123', - field: '', - operator: OperatorEnum.INCLUDED, - type: OperatorTypeEnum.MATCH_ANY, - value: ['jibber jabber'], - }, - ], - field: 'nestedField', - type: OperatorTypeEnum.NESTED, - }, - }; - expect(output).toEqual(expected); - }); - }); - - describe('#getEntryOnListChange', () => { - test('it returns entry with updated value', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: 'ip', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); - }); - - test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { - const payload: FormattedBuilderEntry = { - ...getMockBuilderEntry(), - operator: isOneOfOperator, - value: '1234', - field: undefined, - }; - const output = getEntryOnListChange(payload, getListResponseMock()); - const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = { - updatedEntry: { - id: '123', - field: '', - type: 'list', - list: { id: 'some-list-id', type: 'ip' }, - operator: OperatorEnum.INCLUDED, - }, - index: 0, - }; - expect(output).toEqual(expected); + expect(output).toEqual({ ...getMockIndexPattern(), fields: [...mockEndpointFields] }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 8afdbce68c69a..0ad9814484a2f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -7,616 +7,21 @@ import uuid from 'uuid'; -import { addIdToItem } from '../../../../../common/add_remove_id_to_item'; -import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; -import { - Entry, - OperatorTypeEnum, - EntryNested, - ExceptionListType, - entriesList, - ListSchema, - OperatorEnum, -} from '../../../../lists_plugin_deps'; -import { - isOperator, - existsOperator, - isOneOfOperator, - EXCEPTION_OPERATORS, - EXCEPTION_OPERATORS_SANS_LISTS, - isNotOperator, - doesNotExistOperator, -} from '../../autocomplete/operators'; -import { OperatorOption } from '../../autocomplete/types'; -import { - BuilderEntry, - FormattedBuilderEntry, - ExceptionsBuilderExceptionItem, - EmptyEntry, - EmptyNestedEntry, -} from '../types'; -import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { OperatorTypeEnum, ExceptionListType, OperatorEnum } from '../../../../lists_plugin_deps'; +import { ExceptionsBuilderExceptionItem, EmptyEntry, EmptyNestedEntry } from '../types'; import exceptionableFields from '../exceptionable_fields.json'; -/** - * Returns filtered index patterns based on the field - if a user selects to - * add nested entry, should only show nested fields, if item is the parent - * field of a nested entry, we only display the parent field - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * set to add a nested field - */ -export const getFilteredIndexPatterns = ( +export const filterIndexPatterns = ( patterns: IIndexPattern, - item: FormattedBuilderEntry, type: ExceptionListType ): IIndexPattern => { - const indexPatterns = { - ...patterns, - fields: patterns.fields.filter(({ name }) => - type === 'endpoint' ? exceptionableFields.includes(name) : true - ), - }; - - if (item.nested === 'child' && item.parent != null) { - // when user has selected a nested entry, only fields with the common parent are shown - return { - ...indexPatterns, - fields: indexPatterns.fields - .filter((indexField) => { - const fieldHasCommonParentPath = - indexField.subType != null && - indexField.subType.nested != null && - item.parent != null && - indexField.subType.nested.path === item.parent.parent.field; - - return fieldHasCommonParentPath; - }) - .map((f) => { - const fieldNameWithoutParentPath = f.name.split('.').slice(-1)[0]; - return { ...f, name: fieldNameWithoutParentPath }; - }), - }; - } else if (item.nested === 'parent' && item.field != null) { - // when user has selected a nested entry, right above it we show the common parent - return { ...indexPatterns, fields: [item.field] }; - } else if (item.nested === 'parent' && item.field == null) { - // when user selects to add a nested entry, only nested fields are shown as options - return { - ...indexPatterns, - fields: indexPatterns.fields.filter( - (field) => field.subType != null && field.subType.nested != null - ), - }; - } else { - return indexPatterns; - } -}; - -/** - * Fields of type 'text' do not generate autocomplete values, we want - * to find it's corresponding keyword type (if available) which does - * generate autocomplete values - * - * @param fields IFieldType fields - * @param selectedField the field name that was selected - * @param isTextType we only want a corresponding keyword field if - * the selected field is of type 'text' - * - */ -export const getCorrespondingKeywordField = ({ - fields, - selectedField, -}: { - fields: IFieldType[]; - selectedField: string | undefined; -}): IFieldType | undefined => { - const selectedFieldBits = - selectedField != null && selectedField !== '' ? selectedField.split('.') : []; - const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; - - if (selectedFieldIsTextType && selectedFieldBits.length > 0) { - const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); - const [foundKeywordField] = fields.filter( - ({ name }) => keywordField !== '' && keywordField === name - ); - return foundKeywordField; - } - - return undefined; -}; - -/** - * Formats the entry into one that is easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param item exception item entry - * @param itemIndex entry index - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntry = ( - indexPattern: IIndexPattern, - item: BuilderEntry, - itemIndex: number, - parent: EntryNested | undefined, - parentIndex: number | undefined -): FormattedBuilderEntry => { - const { fields } = indexPattern; - const field = parent != null ? `${parent.field}.${item.field}` : item.field; - const [foundField] = fields.filter(({ name }) => field != null && field === name); - const correspondingKeywordField = getCorrespondingKeywordField({ - fields, - selectedField: field, - }); - - if (parent != null && parentIndex != null) { - return { - field: - foundField != null - ? { ...foundField, name: foundField.name.split('.').slice(-1)[0] } - : foundField, - correspondingKeywordField, - id: item.id ?? `${itemIndex}`, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: 'child', - parent: { parent, parentIndex }, - entryIndex: itemIndex, - }; - } else { - return { - field: foundField, - id: item.id ?? `${itemIndex}`, - correspondingKeywordField, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - nested: undefined, - parent: undefined, - entryIndex: itemIndex, - }; - } -}; - -export const isEntryNested = (item: BuilderEntry): item is EntryNested => { - return (item as EntryNested).entries != null; -}; - -/** - * Formats the entries to be easily usable for the UI, most of the - * complexity was introduced with nested fields - * - * @param patterns IIndexPattern containing available fields on rule index - * @param entries exception item entries - * @param addNested boolean noting whether or not UI is currently - * set to add a nested field - * @param parent nested entries hold copy of their parent for use in various logic - * @param parentIndex corresponds to the entry index, this might seem obvious, but - * was added to ensure that nested items could be identified with their parent entry - */ -export const getFormattedBuilderEntries = ( - indexPattern: IIndexPattern, - entries: BuilderEntry[], - parent?: EntryNested, - parentIndex?: number -): FormattedBuilderEntry[] => { - return entries.reduce((acc, item, index) => { - const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; - if (item.type !== 'nested' && !isNewNestedEntry) { - const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( - indexPattern, - item, - index, - parent, - parentIndex - ); - return [...acc, newItemEntry]; - } else { - const parentEntry: FormattedBuilderEntry = { - operator: isOperator, - id: item.id ?? `${index}`, - nested: 'parent', - field: isNewNestedEntry - ? undefined - : { - name: item.field ?? '', - aggregatable: false, - searchable: false, - type: 'string', - esTypes: ['nested'], - }, - value: undefined, - entryIndex: index, - parent: undefined, - correspondingKeywordField: undefined, - }; - - // User has selected to add a nested field, but not yet selected the field - if (isNewNestedEntry) { - return [...acc, parentEntry]; + return type === 'endpoint' + ? { + ...patterns, + fields: patterns.fields.filter(({ name }) => exceptionableFields.includes(name)), } - - if (isEntryNested(item)) { - const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); - - return [...acc, parentEntry, ...nestedItems]; - } - - return [...acc]; - } - }, []); -}; - -/** - * Determines whether an entire entry, exception item, or entry within a nested - * entry needs to be removed - * - * @param exceptionItem - * @param entryIndex index of given entry, for nested entries, this will correspond - * to their parent index - * @param nestedEntryIndex index of nested entry - * - */ -export const getUpdatedEntriesOnDelete = ( - exceptionItem: ExceptionsBuilderExceptionItem, - entryIndex: number, - nestedParentIndex: number | null -): ExceptionsBuilderExceptionItem => { - const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex]; - - if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { - const updatedEntryEntries = [ - ...itemOfInterest.entries.slice(0, entryIndex), - ...itemOfInterest.entries.slice(entryIndex + 1), - ]; - - if (updatedEntryEntries.length === 0) { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } else { - const { field } = itemOfInterest; - const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { - field, - id: itemOfInterest.id ?? `${entryIndex}`, - type: OperatorTypeEnum.NESTED, - entries: updatedEntryEntries, - }; - - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, nestedParentIndex), - updatedItemOfInterest, - ...exceptionItem.entries.slice(nestedParentIndex + 1), - ], - }; - } - } else { - return { - ...exceptionItem, - entries: [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ], - }; - } -}; - -/** - * On operator change, determines whether value needs to be cleared or not - * - * @param field - * @param selectedOperator - * @param currentEntry - * - */ -export const getEntryFromOperator = ( - selectedOperator: OperatorOption, - currentEntry: FormattedBuilderEntry -): Entry & { id?: string } => { - const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; - const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; - switch (selectedOperator.type) { - case 'match': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH, - operator: selectedOperator.operator, - value: - isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', - }; - case 'match_any': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.MATCH_ANY, - operator: selectedOperator.operator, - value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], - }; - case 'list': - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.LIST, - operator: selectedOperator.operator, - list: { id: '', type: 'ip' }, - }; - default: - return { - id: currentEntry.id, - field: fieldValue, - type: OperatorTypeEnum.EXISTS, - operator: selectedOperator.operator, - }; - } -}; - -/** - * Determines which operators to make available - * - * @param item - * @param listType - * @param isBoolean - * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators - */ -export const getOperatorOptions = ( - item: FormattedBuilderEntry, - listType: ExceptionListType, - isBoolean: boolean, - includeValueListOperators = true -): OperatorOption[] => { - if (item.nested === 'parent' || item.field == null) { - return [isOperator]; - } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { - return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; - } else if (item.nested != null && listType === 'detection') { - return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; - } else { - return isBoolean - ? [isOperator, isNotOperator, existsOperator, doesNotExistOperator] - : includeValueListOperators - ? EXCEPTION_OPERATORS - : EXCEPTION_OPERATORS_SANS_LISTS; - } -}; - -/** - * Determines proper entry update when user selects new field - * - * @param item - current exception item entry values - * @param newField - newly selected field - * - */ -export const getEntryOnFieldChange = ( - item: FormattedBuilderEntry, - newField: IFieldType -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, nested } = item; - const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; - - if (nested === 'parent') { - // For nested entries, when user first selects to add a nested - // entry, they first see a row similar to what is shown for when - // a user selects "exists", as soon as they make a selection - // we can now identify the 'parent' and 'child' this is where - // we first convert the entry into type "nested" - const newParentFieldValue = - newField.subType != null && newField.subType.nested != null - ? newField.subType.nested.path - : ''; - - return { - updatedEntry: { - id: item.id, - field: newParentFieldValue, - type: OperatorTypeEnum.NESTED, - entries: [ - addIdToItem({ - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }), - ], - }, - index: entryIndex, - }; - } else if (nested === 'child' && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: newChildFieldValue ?? '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: newField != null ? newField.name : '', - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: '', - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user selects new operator - * - * @param item - current exception item entry values - * @param newOperator - newly selected operator - * - */ -export const getEntryOnOperatorChange = ( - item: FormattedBuilderEntry, - newOperator: OperatorOption -): { updatedEntry: BuilderEntry; index: number } => { - const { parent, entryIndex, field, nested } = item; - const newEntry = getEntryFromOperator(newOperator, item); - - if (!entriesList.is(newEntry) && nested != null && parent != null) { - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - ...newEntry, - field: field != null ? field.name.split('.').slice(-1)[0] : '', - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { updatedEntry: newEntry, index: entryIndex }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchChange = ( - item: FormattedBuilderEntry, - newField: string -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "match_any" - * - * @param item - current exception item entry values - * @param newField - newly entered value - * - */ -export const getEntryOnMatchAnyChange = ( - item: FormattedBuilderEntry, - newField: string[] -): { updatedEntry: BuilderEntry; index: number } => { - const { nested, parent, entryIndex, field, operator } = item; - - if (nested != null && parent != null) { - const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; - - return { - updatedEntry: { - ...parent.parent, - entries: [ - ...parent.parent.entries.slice(0, entryIndex), - { - id: item.id, - field: fieldName, - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - ...parent.parent.entries.slice(entryIndex + 1), - ], - }, - index: parent.parentIndex, - }; - } else { - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.MATCH_ANY, - operator: operator.operator, - value: newField, - }, - index: entryIndex, - }; - } -}; - -/** - * Determines proper entry update when user updates value - * when operator is of type "list" - * - * @param item - current exception item entry values - * @param newField - newly selected list - * - */ -export const getEntryOnListChange = ( - item: FormattedBuilderEntry, - newField: ListSchema -): { updatedEntry: BuilderEntry; index: number } => { - const { entryIndex, field, operator } = item; - const { id, type } = newField; - - return { - updatedEntry: { - id: item.id, - field: field != null ? field.name : '', - type: OperatorTypeEnum.LIST, - operator: operator.operator, - list: { id, type }, - }, - index: entryIndex, - }; + : patterns; }; export const getDefaultEmptyEntry = (): EmptyEntry => ({ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx index 4d0e3306e3315..2863b92ca68ab 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.test.tsx @@ -17,37 +17,26 @@ import { import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; -import { useKibana } from '../../../../common/lib/kibana'; import { getEmptyValue } from '../../empty_value'; import { ExceptionBuilderComponent } from './'; import { getMockTheme } from '../../../lib/kibana/kibana_react.mock'; +import { coreMock } from 'src/core/public/mocks'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece', }, }); - -jest.mock('../../../../common/lib/kibana'); +const mockKibanaHttpService = coreMock.createStart().http; +const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); describe('ExceptionBuilderComponent', () => { let wrapper: ReactWrapper; const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - afterEach(() => { getValueSuggestionsMock.mockClear(); jest.clearAllMocks(); @@ -58,6 +47,8 @@ describe('ExceptionBuilderComponent', () => { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( { wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 954a75fc370bd..e33478ad99660 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -98,7 +98,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ onConfirm, onRuleChange, }: EditExceptionModalProps) { - const { http } = useKibana().services; + const { http, data } = useKibana().services; const [comment, setComment] = useState(''); const [errorsExist, setErrorExists] = useState(false); const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId); @@ -313,6 +313,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.EXCEPTION_BUILDER_INFO} any> = (...args: Parameters) => ReturnType; // eslint-disable-line diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index 404ee0cd4aa2c..40b843a676d9c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -54,7 +54,7 @@ export const mockEndpointResultList: (options?: { for (let index = 0; index < actualCountToReturn; index++) { hosts.push({ metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: queryStrategyVersion, }); } @@ -74,7 +74,7 @@ export const mockEndpointResultList: (options?: { export const mockEndpointDetailsApiResult = (): HostInfo => { return { metadata: generator.generateHostMetadata(), - host_status: HostStatus.ERROR, + host_status: HostStatus.UNHEALTHY, query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 17ce24e7cda7f..eec4de6400145 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -231,7 +231,7 @@ export const showView: (state: EndpointState) => 'policy_response' | 'details' = export const hostStatusInfo: (state: Immutable) => HostStatus = createSelector( (state) => state.hostStatus, (hostStatus) => { - return hostStatus ? hostStatus : HostStatus.ERROR; + return hostStatus ? hostStatus : HostStatus.UNHEALTHY; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx index eb3e534ba427f..c97e097ea9b72 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoint_details.tsx @@ -8,7 +8,6 @@ import styled from 'styled-components'; import { EuiDescriptionList, - EuiHealth, EuiHorizontalRule, EuiListGroup, EuiListGroupItem, @@ -17,6 +16,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, + EuiSpacer, } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,11 +26,7 @@ import { HostInfo, HostMetadata, HostStatus } from '../../../../../../common/end import { useEndpointSelector, useAgentDetailsIngestUrl } from '../hooks'; import { useNavigateToAppEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { policyResponseStatus, uiQueryParams } from '../../store/selectors'; -import { - POLICY_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_BADGE_COLOR, - HOST_STATUS_TO_HEALTH_COLOR, -} from '../host_constants'; +import { POLICY_STATUS_TO_BADGE_COLOR, HOST_STATUS_TO_BADGE_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; @@ -48,17 +44,6 @@ const HostIds = styled(EuiListGroupItem)` } `; -const LinkToExternalApp = styled.div` - margin-top: ${(props) => props.theme.eui.ruleMargins.marginMedium}; - .linkToAppIcon { - margin-right: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - vertical-align: top; - } - .linkToAppPopoutIcon { - margin-left: ${(props) => props.theme.eui.ruleMargins.marginXSmall}; - } -`; - const openReassignFlyoutSearch = '?openReassignFlyout=true'; export const EndpointDetails = memo( @@ -80,7 +65,7 @@ export const EndpointDetails = memo( const queryParams = useEndpointSelector(uiQueryParams); const policyStatus = useEndpointSelector( policyResponseStatus - ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; + ) as keyof typeof POLICY_STATUS_TO_BADGE_COLOR; const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { @@ -89,32 +74,37 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.os', { defaultMessage: 'OS', }), - description: details.host.os.full, + description: {details.host.os.full}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.agentStatus', { defaultMessage: 'Agent Status', }), description: ( - - + ), }, { title: i18n.translate('xpack.securitySolution.endpoint.details.lastSeen', { defaultMessage: 'Last Seen', }), - description: , + description: ( + + {' '} + + + ), }, ]; }, [details, hostStatus]); @@ -169,12 +159,14 @@ export const EndpointDetails = memo( description: ( - - {details.Endpoint.policy.applied.name} - + + + {details.Endpoint.policy.applied.name} + + {details.Endpoint.policy.applied.endpoint_policy_version && ( @@ -241,9 +233,11 @@ export const EndpointDetails = memo( }), description: ( - {details.host.ip.map((ip: string, index: number) => ( - - ))} + + {details.host.ip.map((ip: string, index: number) => ( + + ))} + ), }, @@ -251,13 +245,13 @@ export const EndpointDetails = memo( title: i18n.translate('xpack.securitySolution.endpoint.details.hostname', { defaultMessage: 'Hostname', }), - description: details.host.hostname, + description: {details.host.hostname}, }, { title: i18n.translate('xpack.securitySolution.endpoint.details.endpointVersion', { defaultMessage: 'Endpoint Version', }), - description: details.agent.version, + description: {details.agent.version}, }, ]; }, [details.agent.version, details.host.hostname, details.host.ip]); @@ -275,22 +269,36 @@ export const EndpointDetails = memo( listItems={detailsResultsPolicy} data-test-subj="endpointDetailsPolicyList" /> - - + + - - - - - + + + + + + + + + + + + + ({ - [HostStatus.ERROR]: 'danger', - [HostStatus.ONLINE]: 'success', - [HostStatus.OFFLINE]: 'subdued', - [HostStatus.UNENROLLING]: 'warning', + [HostStatus.HEALTHY]: 'secondary', + [HostStatus.UNHEALTHY]: 'warning', + [HostStatus.UPDATING]: 'primary', + [HostStatus.OFFLINE]: 'default', + [HostStatus.INACTIVE]: 'default', }); export const POLICY_STATUS_TO_HEALTH_COLOR = Object.freeze< { [key in keyof typeof HostPolicyResponseActionStatus]: string } >({ - success: 'success', + success: 'secondary', warning: 'warning', failure: 'danger', - unsupported: 'subdued', + unsupported: 'default', }); export const POLICY_STATUS_TO_BADGE_COLOR = Object.freeze< diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 79e91fdeb813a..17ebff603ccfb 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -25,7 +25,7 @@ import { MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; -import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; +import { POLICY_STATUS_TO_TEXT } from './host_constants'; import { mockPolicyResultList } from '../../policy/store/test_mock_utils'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; @@ -232,9 +232,10 @@ describe('when on the list page', () => { > = []; let firstPolicyID: string; let firstPolicyRev: number; + beforeEach(() => { reactTestingLibrary.act(() => { - const mockedEndpointData = mockEndpointResultList({ total: 4 }); + const mockedEndpointData = mockEndpointResultList({ total: 5 }); const hostListData = mockedEndpointData.hosts; const queryStrategyVersion = mockedEndpointData.query_strategy_version; @@ -259,9 +260,9 @@ describe('when on the list page', () => { }; [ - { status: HostStatus.ERROR, policy: (p: Policy) => p }, + { status: HostStatus.UNHEALTHY, policy: (p: Policy) => p }, { - status: HostStatus.ONLINE, + status: HostStatus.HEALTHY, policy: (p: Policy) => { p.endpoint.id = 'xyz'; // represents change in endpoint policy assignment p.endpoint.revision = 1; @@ -276,7 +277,14 @@ describe('when on the list page', () => { }, }, { - status: HostStatus.UNENROLLING, + status: HostStatus.UPDATING, + policy: (p: Policy) => { + p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet + return p; + }, + }, + { + status: HostStatus.INACTIVE, policy: (p: Policy) => { p.agent.configured.revision += 1; // agent policy change, not propagated to agent yet return p; @@ -317,7 +325,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const rows = await renderResult.findAllByRole('row'); - expect(rows).toHaveLength(5); + expect(rows).toHaveLength(6); }); it('should show total', async () => { const renderResult = render(); @@ -325,7 +333,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const total = await renderResult.findByTestId('endpointListTableTotal'); - expect(total.textContent).toEqual('4 Hosts'); + expect(total.textContent).toEqual('5 Hosts'); }); it('should display correct status', async () => { const renderResult = render(); @@ -334,23 +342,30 @@ describe('when on the list page', () => { }); const hostStatuses = await renderResult.findAllByTestId('rowHostStatus'); - expect(hostStatuses[0].textContent).toEqual('Error'); - expect(hostStatuses[0].querySelector('[data-euiicon-type][color="danger"]')).not.toBeNull(); + expect(hostStatuses[0].textContent).toEqual('Unhealthy'); + expect(hostStatuses[0].getAttribute('style')).toMatch( + /background-color\: rgb\(241\, 216\, 111\)\;/ + ); - expect(hostStatuses[1].textContent).toEqual('Online'); - expect( - hostStatuses[1].querySelector('[data-euiicon-type][color="success"]') - ).not.toBeNull(); + expect(hostStatuses[1].textContent).toEqual('Healthy'); + expect(hostStatuses[1].getAttribute('style')).toMatch( + /background-color\: rgb\(109\, 204\, 177\)\;/ + ); expect(hostStatuses[2].textContent).toEqual('Offline'); - expect( - hostStatuses[2].querySelector('[data-euiicon-type][color="subdued"]') - ).not.toBeNull(); + expect(hostStatuses[2].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); - expect(hostStatuses[3].textContent).toEqual('Unenrolling'); - expect( - hostStatuses[3].querySelector('[data-euiicon-type][color="warning"]') - ).not.toBeNull(); + expect(hostStatuses[3].textContent).toEqual('Updating'); + expect(hostStatuses[3].getAttribute('style')).toMatch( + /background-color\: rgb\(121\, 170\, 217\)\;/ + ); + + expect(hostStatuses[4].textContent).toEqual('Inactive'); + expect(hostStatuses[4].getAttribute('style')).toMatch( + /background-color\: rgb\(211\, 218\, 230\)\;/ + ); }); it('should display correct policy status', async () => { @@ -361,14 +376,18 @@ describe('when on the list page', () => { const policyStatuses = await renderResult.findAllByTestId('rowPolicyStatus'); policyStatuses.forEach((status, index) => { + const policyStatusToRGBColor: Array<[string, string]> = [ + ['Success', 'background-color: rgb(109, 204, 177);'], + ['Warning', 'background-color: rgb(241, 216, 111);'], + ['Failure', 'background-color: rgb(255, 126, 98);'], + ['Unsupported', 'background-color: rgb(211, 218, 230);'], + ]; + const policyStatusStyleMap: ReadonlyMap = new Map( + policyStatusToRGBColor + ); + const expectedStatusColor: string = policyStatusStyleMap.get(status.textContent!) ?? ''; expect(status.textContent).toEqual(POLICY_STATUS_TO_TEXT[generatedPolicyStatuses[index]]); - expect( - status.querySelector( - `[data-euiicon-type][color=${ - POLICY_STATUS_TO_HEALTH_COLOR[generatedPolicyStatuses[index]] - }]` - ) - ).not.toBeNull(); + expect(status.getAttribute('style')).toMatch(expectedStatusColor); }); }); @@ -378,7 +397,7 @@ describe('when on the list page', () => { await middlewareSpy.waitForAction('serverReturnedEndpointList'); }); const outOfDates = await renderResult.findAllByTestId('rowPolicyOutOfDate'); - expect(outOfDates).toHaveLength(3); + expect(outOfDates).toHaveLength(4); outOfDates.forEach((item, index) => { expect(item.textContent).toEqual('Out-of-date'); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c4c27bd493950..d28bf6b38fd31 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -5,14 +5,14 @@ * 2.0. */ -import React, { useMemo, useCallback, memo, useState } from 'react'; +import React, { useMemo, useCallback, memo, useState, useContext } from 'react'; import { EuiHorizontalRule, EuiBasicTable, EuiBasicTableColumn, EuiText, EuiLink, - EuiHealth, + EuiBadge, EuiToolTip, EuiSelectableProps, EuiSuperDatePicker, @@ -33,13 +33,14 @@ import { createStructuredSelector } from 'reselect'; import { useDispatch } from 'react-redux'; import { EuiContextMenuItemProps } from '@elastic/eui/src/components/context_menu/context_menu_item'; import { NavigateToAppOptions } from 'kibana/public'; +import { ThemeContext } from 'styled-components'; import { EndpointDetailsFlyout } from './details'; import * as selectors from '../store/selectors'; import { useEndpointSelector } from './hooks'; import { isPolicyOutOfDate } from '../utils'; import { - HOST_STATUS_TO_HEALTH_COLOR, - POLICY_STATUS_TO_HEALTH_COLOR, + HOST_STATUS_TO_BADGE_COLOR, + POLICY_STATUS_TO_BADGE_COLOR, POLICY_STATUS_TO_TEXT, } from './host_constants'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; @@ -72,11 +73,24 @@ const EndpointListNavLink = memo<{ name: string; href: string; route: string; + isBadge?: boolean; dataTestSubj: string; -}>(({ name, href, route, dataTestSubj }) => { +}>(({ name, href, route, isBadge = false, dataTestSubj }) => { const clickHandler = useNavigateByRouterEventHandler(route); + const theme = useContext(ThemeContext); - return ( + return isBadge ? ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {name} + + ) : ( // eslint-disable-next-line @elastic/eui/href-or-on-click { // eslint-disable-next-line react/display-name render: (hostStatus: HostInfo['host_status']) => { return ( - - + ); }, }, @@ -375,8 +389,8 @@ export const EndpointList = () => { }); const toRouteUrl = formatUrl(toRoutePath); return ( - @@ -384,9 +398,10 @@ export const EndpointList = () => { name={POLICY_STATUS_TO_TEXT[policy.status]} href={toRouteUrl} route={toRoutePath} + isBadge dataTestSubj="policyStatusCellLink" /> - + ); }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap index 35fc520558d6a..f0831815fb65c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -1512,13 +1512,13 @@ Object { data-test-subj="trustedAppsListPage" >