diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 40be42461c493..5340b4bf578cd 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -162,10 +162,12 @@ enabled: - x-pack/test/functional/apps/maps/group2/config.ts - x-pack/test/functional/apps/maps/group3/config.ts - x-pack/test/functional/apps/maps/group4/config.ts + - x-pack/test/functional/apps/ml/anomaly_detection/config.ts + - x-pack/test/functional/apps/ml/data_frame_analytics/config.ts - x-pack/test/functional/apps/ml/data_visualizer/config.ts - - x-pack/test/functional/apps/ml/group1/config.ts - - x-pack/test/functional/apps/ml/group2/config.ts - - x-pack/test/functional/apps/ml/group3/config.ts + - x-pack/test/functional/apps/ml/permissions/config.ts + - x-pack/test/functional/apps/ml/short_tests/config.ts + - x-pack/test/functional/apps/ml/stack_management_jobs/config.ts - x-pack/test/functional/apps/monitoring/config.ts - x-pack/test/functional/apps/remote_clusters/config.ts - x-pack/test/functional/apps/reporting_management/config.ts diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index d848470b3ff68..e8509e8126adb 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -8,7 +8,7 @@ "name": "kibana-buildkite", "version": "1.0.0", "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be" } }, "node_modules/@nodelib/fs.scandir": { @@ -184,11 +184,24 @@ "follow-redirects": "^1.14.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -355,14 +368,15 @@ }, "node_modules/kibana-buildkite-library": { "version": "1.0.0", - "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", - "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", + "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#a0037514b7650296a23dbad99b165601d4eab1be", + "integrity": "sha512-W9oH2c0q21IbO3sKJR2BkebhDlXVuWfqKO1r6T/E8/RRxCXJg/Wf073k8aDdpl1Enk8Pq47F+lG7/IVT+kAcFA==", "license": "MIT", "dependencies": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "node_modules/merge2": { @@ -385,6 +399,17 @@ "node": ">=8.6" } }, + "node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -684,11 +709,24 @@ "follow-redirects": "^1.14.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -801,14 +839,15 @@ } }, "kibana-buildkite-library": { - "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", - "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", - "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4", + "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#a0037514b7650296a23dbad99b165601d4eab1be", + "integrity": "sha512-W9oH2c0q21IbO3sKJR2BkebhDlXVuWfqKO1r6T/E8/RRxCXJg/Wf073k8aDdpl1Enk8Pq47F+lG7/IVT+kAcFA==", + "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be", "requires": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "merge2": { @@ -825,6 +864,14 @@ "picomatch": "^2.3.1" } }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", diff --git a/.buildkite/package.json b/.buildkite/package.json index 4e46ba6637027..7f15a2fdf75bc 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be" } } diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index e5ed00f760864..390adc2dbacee 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -41,9 +41,9 @@ trap "echoKibanaLogs" EXIT vagrant provision "$TEST_PACKAGE" -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" -export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 +# export TEST_BROWSER_HEADLESS=1 +# export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" +# export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke +# cd x-pack +# node scripts/functional_test_runner.js --include-tag=smoke diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 156a306b12e89..e98ba1d451ff3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -83,6 +83,7 @@ /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 +/src/plugins/dashboard/public/application/embeddable/viewport/print_media @elastic/kibana-app-services ### Observability Plugins diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 63e104c44b173..a91776fde65ba 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -222,7 +222,7 @@ It also provides a stateful version of it on the start contract. |{kib-repo}blob/{branch}/src/plugins/newsfeed/README.md[newsfeed] |The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. -Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. +Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. |{kib-repo}blob/{branch}/src/plugins/presentation_util/README.mdx[presentationUtil] diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index ff62f5c019b74..c53adf90ec3a2 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -112,7 +112,7 @@ filter { "type" => "%{[resultSet][vehicle][type]}" "vehicle_id" => "%{[resultSet][vehicle][vehicleID]}" } - remove_field => [ "resultSet", "@version", "@timestamp" ] + remove_field => [ "resultSet", "@version", "@timestamp", "[event][original]" ] } mutate { diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 7441621f441f9..2cfd3169b45a3 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -38,71 +38,69 @@ If you'd like to change any of the default values, copy and paste the relevant settings into your `kibana.yml` configuration file. Changing these settings may disable features of the APM App. -[cols="2*<"] -|=== -| `xpack.apm.maxServiceEnvironments` {ess-icon} - | Maximum number of unique service environments recognized by the UI. Defaults to `100`. +`xpack.apm.maxServiceEnvironments` {ess-icon}:: +Maximum number of unique service environments recognized by the UI. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintBucketSize` {ess-icon} - | Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. +`xpack.apm.serviceMapFingerprintBucketSize` {ess-icon}:: +Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon} - | Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. +`xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon}:: +Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. -| `xpack.apm.serviceMapEnabled` {ess-icon} - | Set to `false` to disable service maps. Defaults to `true`. +`xpack.apm.serviceMapEnabled` {ess-icon}:: +Set to `false` to disable service maps. Defaults to `true`. -| `xpack.apm.serviceMapTraceIdBucketSize` {ess-icon} - | Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. +`xpack.apm.serviceMapTraceIdBucketSize` {ess-icon}:: +Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. -| `xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon} - | Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. +`xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon}:: +Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. -| `xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon} - | Maximum number of traces per request for generating the global service map. Defaults to `50`. +`xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon}:: +Maximum number of traces per request for generating the global service map. Defaults to `50`. -| `xpack.apm.ui.enabled` {ess-icon} - | Set to `false` to hide the APM app from the main menu. Defaults to `true`. +`xpack.apm.ui.enabled` {ess-icon}:: +Set to `false` to hide the APM app from the main menu. Defaults to `true`. -| `xpack.apm.ui.transactionGroupBucketSize` {ess-icon} - | Number of top transaction groups displayed in the APM app. Defaults to `1000`. +`xpack.apm.ui.transactionGroupBucketSize` {ess-icon}:: +Number of top transaction groups displayed in the APM app. Defaults to `1000`. -| `xpack.apm.ui.maxTraceItems` {ess-icon} - | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. +`xpack.apm.ui.maxTraceItems` {ess-icon}:: +Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -| `xpack.observability.annotations.index` {ess-icon} - | Index name where Observability annotations are stored. Defaults to `observability-annotations`. +`xpack.observability.annotations.index` {ess-icon}:: +Index name where Observability annotations are stored. Defaults to `observability-annotations`. -| `xpack.apm.searchAggregatedTransactions` {ess-icon} - | Enables Transaction histogram metrics. Defaults to `auto` so the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. When set to `never` and aggregated transactions are not used. - See {apm-guide-ref}/transaction-metrics.html[Configure transaction metrics] for more information. +`xpack.apm.searchAggregatedTransactions` {ess-icon}:: +Enables Transaction histogram metrics. Defaults to `auto` so the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. When set to `never` and aggregated transactions are not used. ++ +See {apm-guide-ref}/transaction-metrics.html[Configure transaction metrics] for more information. -| `xpack.apm.metricsInterval` {ess-icon} - | Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. +`xpack.apm.metricsInterval` {ess-icon}:: +Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. -| `xpack.apm.agent.migrations.enabled` {ess-icon} - | Set to `false` to disable cloud APM migrations. Defaults to `true`. +`xpack.apm.agent.migrations.enabled` {ess-icon}:: +Set to `false` to disable cloud APM migrations. Defaults to `true`. -| `xpack.apm.indices.error` {ess-icon} - | Matcher for all error indices. Defaults to `logs-apm*,apm-*`. +`xpack.apm.indices.error` {ess-icon}:: +Matcher for all error indices. Defaults to `logs-apm*,apm-*`. -| `xpack.apm.indices.onboarding` {ess-icon} - | Matcher for all onboarding indices. Defaults to `apm-*`. +`xpack.apm.indices.onboarding` {ess-icon}:: +Matcher for all onboarding indices. Defaults to `apm-*`. -| `xpack.apm.indices.span` {ess-icon} - | Matcher for all span indices. Defaults to `traces-apm*,apm-*`. +`xpack.apm.indices.span` {ess-icon}:: +Matcher for all span indices. Defaults to `traces-apm*,apm-*`. -| `xpack.apm.indices.transaction` {ess-icon} - | Matcher for all transaction indices. Defaults to `traces-apm*,apm-*`. +`xpack.apm.indices.transaction` {ess-icon}:: +Matcher for all transaction indices. Defaults to `traces-apm*,apm-*`. -| `xpack.apm.indices.metric` {ess-icon} - | Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*`. +`xpack.apm.indices.metric` {ess-icon}:: +Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*`. -| `xpack.apm.indices.sourcemap` {ess-icon} - | Matcher for all source map indices. Defaults to `apm-*`. +`xpack.apm.indices.sourcemap` {ess-icon}:: +Matcher for all source map indices. Defaults to `apm-*`. -| `xpack.apm.autoCreateApmDataView` {ess-icon} - | Set to `false` to disable the automatic creation of the APM data view when the APM app is opened. Defaults to `true`. -|=== +`xpack.apm.autoCreateApmDataView` {ess-icon}:: +Set to `false` to disable the automatic creation of the APM data view when the APM app is opened. Defaults to `true`. // end::general-apm-settings[] diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 5ddf45887a530..ddce9feb3e640 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -18,104 +18,140 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. [[general-fleet-settings-kb]] ==== General {fleet} settings -[cols="2*<"] -|=== -| `xpack.fleet.agents.enabled` {ess-icon} - | Set to `true` (default) to enable {fleet}. -|=== +`xpack.fleet.agents.enabled` {ess-icon}:: +Set to `true` (default) to enable {fleet}. + [[fleet-data-visualizer-settings]] ==== {package-manager} settings -[cols="2*<"] -|=== -| `xpack.fleet.registryUrl` - | The address to use to reach the {package-manager} registry. -| `xpack.fleet.registryProxyUrl` - | The proxy address to use to reach the {package-manager} registry if an internet connection is not directly available. - Refer to {fleet-guide}/air-gapped.html[Air-gapped environments] for details. +`xpack.fleet.registryUrl`:: +The address to use to reach the {package-manager} registry. + +`xpack.fleet.registryProxyUrl`:: +The proxy address to use to reach the {package-manager} registry if an internet connection is not directly available. +Refer to {fleet-guide}/air-gapped.html[Air-gapped environments] for details. -|=== ==== {fleet} settings -[cols="2*<"] -|=== -| `xpack.fleet.agents.fleet_server.hosts` - | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.hosts` - | Hostnames used by {agent} for accessing {es}. -| `xpack.fleet.agents.elasticsearch.ca_sha256` - | Hash pin used for certificate verification. The pin is a base64-encoded - string of the SHA-256 fingerprint. -|=== +`xpack.fleet.agents.fleet_server.hosts`:: +Hostnames used by {agent} for accessing {fleet-server}. + +`xpack.fleet.agents.elasticsearch.hosts`:: +Hostnames used by {agent} for accessing {es}. +`xpack.fleet.agents.elasticsearch.ca_sha256`:: +Hash pin used for certificate verification. The pin is a base64-encoded string of the SHA-256 fingerprint. + +[role="child_attributes"] ==== Preconfiguration settings (for advanced use cases) Use these settings to pre-define integrations and agent policies that you want {fleet} to load up by default. -[cols="2* {}; interface RunOptions extends ProcOptions { wait: true | RegExp; waitTimeout?: number | false; + onEarlyExit?: (msg: string) => void; } /** @@ -47,16 +48,6 @@ export class ProcRunner { /** * Start a process, tracking it by `name` - * @param {String} name - * @param {Object} options - * @property {String} options.cmd executable to run - * @property {Array?} options.args arguments to provide the executable - * @property {String?} options.cwd current working directory for the process - * @property {RegExp|Boolean} options.wait Should start() wait for some time? Use - * `true` will wait until the proc exits, - * a `RegExp` will wait until that log line - * is found - * @return {Promise} */ async run(name: string, options: RunOptions) { const { @@ -66,6 +57,7 @@ export class ProcRunner { wait = false, waitTimeout = 15 * MINUTE, env = process.env, + onEarlyExit, } = options; const cmd = options.cmd === 'node' ? process.execPath : options.cmd; @@ -89,6 +81,25 @@ export class ProcRunner { stdin, }); + if (onEarlyExit) { + proc.outcomePromise + .then( + (code) => { + if (!proc.stopWasCalled()) { + onEarlyExit(`[${name}] exitted early with ${code}`); + } + }, + (error) => { + if (!proc.stopWasCalled()) { + onEarlyExit(`[${name}] exitted early: ${error.message}`); + } + } + ) + .catch((error) => { + throw new Error(`Error handling early exit: ${error.stack}`); + }); + } + try { if (wait instanceof RegExp) { // wait for process to log matching line diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 79b41112768a6..818825096ffc1 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -603,7 +603,6 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, installElasticAgentStandalone: `${FLEET_DOCS}install-standalone-elastic-agent.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, - upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: `${KIBANA_DOCS}api-keys.html`, onPremRegistry: `${FLEET_DOCS}air-gapped.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 645aad3af2bd2..2e14fccaccd29 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -359,7 +359,6 @@ export interface DocLinks { installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; - upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: string; diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 899a7843a68fc..cf871abe6f18f 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -33,7 +33,7 @@ export function runCli() { string: ['es-url', 'kibana-url', 'config', 'es-ca', 'kibana-ca'], help: ` --config path to an FTR config file that sets --es-url and --kibana-url - default: ${defaultConfigPath} + default: ${Path.relative(process.cwd(), defaultConfigPath)} --es-url url for Elasticsearch, prefer the --config flag --kibana-url url for Kibana, prefer the --config flag --kibana-ca if Kibana url points to https://localhost we default to the CA from @kbn/dev-utils, customize the CA with this flag diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 50ca9fa91e0aa..eecaef06be453 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -215,6 +215,25 @@ exports.Cluster = class Cluster { }), ]); }); + + if (options.onEarlyExit) { + this._outcome + .then( + () => { + if (!this._stopCalled) { + options.onEarlyExit(`ES exitted unexpectedly`); + } + }, + (error) => { + if (!this._stopCalled) { + options.onEarlyExit(`ES exitted unexpectedly: ${error.stack}`); + } + } + ) + .catch((error) => { + throw new Error(`failure handling early exit: ${error.stack}`); + }); + } } /** diff --git a/packages/kbn-es/src/cluster_exec_options.ts b/packages/kbn-es/src/cluster_exec_options.ts index 8ef3b23cd8c51..da21aaf05b139 100644 --- a/packages/kbn-es/src/cluster_exec_options.ts +++ b/packages/kbn-es/src/cluster_exec_options.ts @@ -15,4 +15,5 @@ export interface EsClusterExecOptions { password?: string; skipReadyCheck?: boolean; readyTimeout?: number; + onEarlyExit?: (msg: string) => void; } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9baed7a92a53e..0e7d4939cbc29 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -104,7 +104,7 @@ pageLoadAssetSize: fieldFormats: 65209 kibanaReact: 74422 share: 71239 - uiActions: 35121 + uiActions: 35121 embeddable: 87309 embeddableEnhanced: 22107 uiActionsEnhanced: 38494 @@ -128,3 +128,4 @@ pageLoadAssetSize: screenshotting: 22870 synthetics: 40958 expressionXY: 29000 + kibanaUsageCollection: 16463 diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 184c16f96167f..f2d5a60cd325e 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -19305,7 +19305,7 @@ cmdShim.ifExists = cmdShimIfExists var fs = __webpack_require__("../../node_modules/graceful-fs/graceful-fs.js") -var mkdir = __webpack_require__("../../node_modules/cmd-shim/node_modules/mkdirp/index.js") +var mkdir = __webpack_require__("../../node_modules/mkdirp/index.js") , path = __webpack_require__("path") , toBatchSyntax = __webpack_require__("../../node_modules/cmd-shim/lib/to-batch-syntax.js") , shebangExpr = /^#\!\s*(?:\/usr\/bin\/env)?\s*([^ \t]+=[^ \t]+\s+)*\s*([^ \t]+)(.*)$/ @@ -19598,112 +19598,6 @@ function replaceDollarWithPercentPair(value) { -/***/ }), - -/***/ "../../node_modules/cmd-shim/node_modules/mkdirp/index.js": -/***/ (function(module, exports, __webpack_require__) { - -var path = __webpack_require__("path"); -var fs = __webpack_require__("fs"); -var _0777 = parseInt('0777', 8); - -module.exports = mkdirP.mkdirp = mkdirP.mkdirP = mkdirP; - -function mkdirP (p, opts, f, made) { - if (typeof opts === 'function') { - f = opts; - opts = {}; - } - else if (!opts || typeof opts !== 'object') { - opts = { mode: opts }; - } - - var mode = opts.mode; - var xfs = opts.fs || fs; - - if (mode === undefined) { - mode = _0777 & (~process.umask()); - } - if (!made) made = null; - - var cb = f || function () {}; - p = path.resolve(p); - - xfs.mkdir(p, mode, function (er) { - if (!er) { - made = made || p; - return cb(null, made); - } - switch (er.code) { - case 'ENOENT': - if (path.dirname(p) === p) return cb(er); - mkdirP(path.dirname(p), opts, function (er, made) { - if (er) cb(er, made); - else mkdirP(p, opts, cb, made); - }); - break; - - // In the case of any other error, just see if there's a dir - // there already. If so, then hooray! If not, then something - // is borked. - default: - xfs.stat(p, function (er2, stat) { - // if the stat fails, then that's super weird. - // let the original error be the failure reason. - if (er2 || !stat.isDirectory()) cb(er, made) - else cb(null, made); - }); - break; - } - }); -} - -mkdirP.sync = function sync (p, opts, made) { - if (!opts || typeof opts !== 'object') { - opts = { mode: opts }; - } - - var mode = opts.mode; - var xfs = opts.fs || fs; - - if (mode === undefined) { - mode = _0777 & (~process.umask()); - } - if (!made) made = null; - - p = path.resolve(p); - - try { - xfs.mkdirSync(p, mode); - made = made || p; - } - catch (err0) { - switch (err0.code) { - case 'ENOENT' : - made = sync(path.dirname(p), opts, made); - sync(p, opts, made); - break; - - // In the case of any other error, just see if there's a dir - // there already. If so, then hooray! If not, then something - // is borked. - default: - var stat; - try { - stat = xfs.statSync(p); - } - catch (err1) { - throw err0; - } - if (!stat.isDirectory()) throw err0; - break; - } - } - - return made; -}; - - /***/ }), /***/ "../../node_modules/color-convert/conversions.js": @@ -36304,6 +36198,112 @@ function isConstructorOrProto (obj, key) { } +/***/ }), + +/***/ "../../node_modules/mkdirp/index.js": +/***/ (function(module, exports, __webpack_require__) { + +var path = __webpack_require__("path"); +var fs = __webpack_require__("fs"); +var _0777 = parseInt('0777', 8); + +module.exports = mkdirP.mkdirp = mkdirP.mkdirP = mkdirP; + +function mkdirP (p, opts, f, made) { + if (typeof opts === 'function') { + f = opts; + opts = {}; + } + else if (!opts || typeof opts !== 'object') { + opts = { mode: opts }; + } + + var mode = opts.mode; + var xfs = opts.fs || fs; + + if (mode === undefined) { + mode = _0777 & (~process.umask()); + } + if (!made) made = null; + + var cb = f || function () {}; + p = path.resolve(p); + + xfs.mkdir(p, mode, function (er) { + if (!er) { + made = made || p; + return cb(null, made); + } + switch (er.code) { + case 'ENOENT': + if (path.dirname(p) === p) return cb(er); + mkdirP(path.dirname(p), opts, function (er, made) { + if (er) cb(er, made); + else mkdirP(p, opts, cb, made); + }); + break; + + // In the case of any other error, just see if there's a dir + // there already. If so, then hooray! If not, then something + // is borked. + default: + xfs.stat(p, function (er2, stat) { + // if the stat fails, then that's super weird. + // let the original error be the failure reason. + if (er2 || !stat.isDirectory()) cb(er, made) + else cb(null, made); + }); + break; + } + }); +} + +mkdirP.sync = function sync (p, opts, made) { + if (!opts || typeof opts !== 'object') { + opts = { mode: opts }; + } + + var mode = opts.mode; + var xfs = opts.fs || fs; + + if (mode === undefined) { + mode = _0777 & (~process.umask()); + } + if (!made) made = null; + + p = path.resolve(p); + + try { + xfs.mkdirSync(p, mode); + made = made || p; + } + catch (err0) { + switch (err0.code) { + case 'ENOENT' : + made = sync(path.dirname(p), opts, made); + sync(p, opts, made); + break; + + // In the case of any other error, just see if there's a dir + // there already. If so, then hooray! If not, then something + // is borked. + default: + var stat; + try { + stat = xfs.statSync(p); + } + catch (err1) { + throw err0; + } + if (!stat.isDirectory()) throw err0; + break; + } + } + + return made; +}; + + /***/ }), /***/ "../../node_modules/multimatch/index.js": diff --git a/packages/kbn-shared-ux-components/BUILD.bazel b/packages/kbn-shared-ux-components/BUILD.bazel index 8eca4da014493..b1420f5376041 100644 --- a/packages/kbn-shared-ux-components/BUILD.bazel +++ b/packages/kbn-shared-ux-components/BUILD.bazel @@ -40,8 +40,10 @@ NPM_MODULE_EXTRA_FILES = [ # "@npm//name-of-package" # eg. "@npm//lodash" RUNTIME_DEPS = [ - "//packages/kbn-i18n", "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/shared-ux/avatar/solution", + "//packages/shared-ux/link/redirect_app", "//packages/kbn-shared-ux-services", "//packages/kbn-shared-ux-storybook", "//packages/kbn-shared-ux-utility", @@ -51,6 +53,7 @@ RUNTIME_DEPS = [ "@npm//classnames", "@npm//react-use", "@npm//react", + "@npm//rxjs", "@npm//url-loader", ] @@ -64,12 +67,14 @@ RUNTIME_DEPS = [ # # References to NPM packages work the same as RUNTIME_DEPS TYPES_DEPS = [ - "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-ambient-ui-types", "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/shared-ux/avatar/solution:npm_module_types", + "//packages/shared-ux/link/redirect_app:npm_module_types", "//packages/kbn-shared-ux-services:npm_module_types", "//packages/kbn-shared-ux-storybook:npm_module_types", "//packages/kbn-shared-ux-utility:npm_module_types", - "//packages/kbn-ambient-ui-types", "@npm//@types/node", "@npm//@types/jest", "@npm//@types/react", @@ -78,6 +83,7 @@ TYPES_DEPS = [ "@npm//@emotion/css", "@npm//@elastic/eui", "@npm//react-use", + "@npm//rxjs", ] jsts_transpiler( diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 05afc94f782c8..77586e8592b6a 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -15,8 +15,6 @@ export const LazyToolbarButton = React.lazy(() => })) ); -export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links')); - /** * A `ToolbarButton` component that is wrapped by the `withSuspense` HOC. This component can * be used directly by consumers and will load the `LazyToolbarButton` component lazily with @@ -100,23 +98,6 @@ export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => */ export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); -/** - * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or - * the withSuspense` HOC to load this component. - */ -export const KibanaSolutionAvatarLazy = React.lazy(() => - import('./solution_avatar').then(({ KibanaSolutionAvatar }) => ({ - default: KibanaSolutionAvatar, - })) -); - -/** - * A `KibanaSolutionAvatar` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavAvatarLazy` component lazily with - * a predefined fallback and error boundary. - */ -export const KibanaSolutionAvatar = withSuspense(KibanaSolutionAvatarLazy); - /** * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the * `withSuspense` HOC to load this component. diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index 66b085b284391..0046e9c3fd3c1 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -7,7 +7,7 @@ exports[`NoDataPage render 1`] = ` - - - + `; exports[`ElasticAgentCardComponent props href 1`] = ` - - - + `; exports[`ElasticAgentCardComponent renders 1`] = ` - - - + `; exports[`ElasticAgentCardComponent renders with canAccessFleet false 1`] = ` - + This integration is not yet enabled. Your administrator has the required permissions to turn it on. + + } + image="test-file-stub" + isDisabled={true} + title={ + + Contact your administrator + } - navigateToUrl={[MockFunction]} -> - - This integration is not yet enabled. Your administrator has the required permissions to turn it on. - - } - image="test-file-stub" - isDisabled={true} - title={ - - Contact your administrator - - } - /> - +/> `; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index 79c0ea245b6cb..b15f254a5274a 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -4,7 +4,9 @@ exports[`ElasticAgentCard renders 1`] = ` - - -
- + - - Add Elastic Agent - - } - href="/app/integrations/browse" - image="test-file-stub" - paddingSize="l" - title="Add Elastic Agent" +
- - - - , - ], - }, + + + Add Elastic Agent + } - } - /> - -
-
-
- -
-
-
- - - - Add Elastic Agent - - - - + + , + ], + }, + } + } + isStringTag={false} + serialized={ + Object { + "map": undefined, + "name": "1hu4pg0-EuiCard", + "next": undefined, + "styles": "max-width:400px;margin-inline:auto;;label:EuiCard;", + "toString": [Function], + } + } + /> +
-

- Use Elastic Agent for a simple, unified way to collect data from your machines. -

-
-
-
-
- - -
+
- - Add Elastic Agent - + - - - - -
-
-
-
- - -
-
- + + +
+

+ Use Elastic Agent for a simple, unified way to collect data from your machines. +

+
+
+
+
+ + + + + +
+ + + + +
+ + + + + + `; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx index f25edb069c629..367fcd10b96a9 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx @@ -10,31 +10,15 @@ import { shallow } from 'enzyme'; import React from 'react'; import { ElasticAgentCardComponent } from './elastic_agent_card.component'; import { NoDataCard } from './no_data_card'; -import { Subject } from 'rxjs'; describe('ElasticAgentCardComponent', () => { - const navigateToUrl = jest.fn(); - const currentAppId$ = new Subject().asObservable(); - test('renders', () => { - const component = shallow( - - ); + const component = shallow(); expect(component).toMatchSnapshot(); }); test('renders with canAccessFleet false', () => { - const component = shallow( - - ); + const component = shallow(); expect(component.find(NoDataCard).props().isDisabled).toBe(true); expect(component).toMatchSnapshot(); }); @@ -42,12 +26,7 @@ describe('ElasticAgentCardComponent', () => { describe('props', () => { test('button', () => { const component = shallow( - + ); expect(component.find(NoDataCard).props().button).toBe('Button'); expect(component).toMatchSnapshot(); @@ -55,12 +34,7 @@ describe('ElasticAgentCardComponent', () => { test('href', () => { const component = shallow( - + ); expect(component.find(NoDataCard).props().href).toBe('some path'); expect(component).toMatchSnapshot(); diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx index 0bca3929f4c2d..7b046bbe3fe8c 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx @@ -9,16 +9,12 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTextColor } from '@elastic/eui'; -import { Observable } from 'rxjs'; import { ElasticAgentCardProps } from './types'; import { NoDataCard } from './no_data_card'; import ElasticAgentCardIllustration from './assets/elastic_agent_card.svg'; -import { RedirectAppLinks } from '../../../redirect_app_links'; export type ElasticAgentCardComponentProps = ElasticAgentCardProps & { canAccessFleet: boolean; - navigateToUrl: (url: string) => Promise; - currentAppId$: Observable; }; const noPermissionTitle = i18n.translate( @@ -54,32 +50,19 @@ const elasticAgentCardDescription = i18n.translate( */ export const ElasticAgentCardComponent: FunctionComponent = ({ canAccessFleet, - title, - navigateToUrl, - currentAppId$, + title = elasticAgentCardTitle, ...cardRest }) => { - const noAccessCard = ( - {noPermissionTitle}} - description={{noPermissionDescription}} - isDisabled - {...cardRest} - /> - ); - const card = ( - - ); + const props = canAccessFleet + ? { + title, + description: elasticAgentCardDescription, + } + : { + title: {noPermissionTitle}, + description: {noPermissionDescription}, + isDisabled: true, + }; - return ( - - {canAccessFleet ? card : noAccessCard} - - ); + return ; }; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx index 77c41cddde6da..84cbfb1c73a94 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx @@ -7,29 +7,23 @@ */ import React from 'react'; -import { applicationServiceFactory } from '@kbn/shared-ux-storybook'; import { - ElasticAgentCardComponent, - ElasticAgentCardComponentProps, + ElasticAgentCardComponent as Component, + ElasticAgentCardComponentProps as ComponentProps, } from './elastic_agent_card.component'; +import { ElasticAgentCard } from './elastic_agent_card'; + export default { title: 'Page Template/No Data/Elastic Agent Data Card', description: 'A solution-specific wrapper around NoDataCard, to be used on NoData page', }; -type Params = Pick; +type Params = Pick; export const PureComponent = (params: Params) => { - const { currentAppId$, navigateToUrl } = applicationServiceFactory(); - return ( - - ); + return ; }; PureComponent.argTypes = { @@ -38,3 +32,7 @@ PureComponent.argTypes = { defaultValue: true, }, }; + +export const ConnectedComponent = () => { + return ; +}; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index 42d42dd805650..3702dd4a456a7 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -6,8 +6,10 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useApplication, useHttp, usePermissions } from '@kbn/shared-ux-services'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import useObservable from 'react-use/lib/useObservable'; import { ElasticAgentCardProps } from './types'; import { ElasticAgentCardComponent } from './elastic_agent_card.component'; @@ -16,27 +18,28 @@ export const ElasticAgentCard = (props: ElasticAgentCardProps) => { const { canAccessFleet } = usePermissions(); const { addBasePath } = useHttp(); const { navigateToUrl, currentAppId$ } = useApplication(); + const currentAppId = useObservable(currentAppId$); - const createHref = () => { - const { href, category } = props; - if (href) { - return href; + const { href: srcHref, category } = props; + + const href = useMemo(() => { + if (srcHref) { + return srcHref; } + // TODO: get this URL from a locator const prefix = '/app/integrations/browse'; + if (category) { return addBasePath(`${prefix}/${category}`); } + return addBasePath(prefix); - }; + }, [addBasePath, srcHref, category]); return ( - + + + ); }; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx index f16f87039a626..837eb5282507f 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx @@ -7,14 +7,15 @@ */ import React, { useMemo, FunctionComponent } from 'react'; -import { i18n } from '@kbn/i18n'; +import classNames from 'classnames'; import { EuiLink, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import classNames from 'classnames'; +import { KibanaSolutionAvatar } from '@kbn/shared-ux-avatar-solution'; + import { ElasticAgentCard } from './no_data_card'; import { NoDataPageProps } from './types'; -import { KibanaSolutionAvatar } from '../../solution_avatar'; export const NoDataPage: FunctionComponent = ({ solution, diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap index fce0e996d99cd..069192708e47b 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap @@ -178,7 +178,7 @@ exports[`KibanaPageTemplateSolutionNav renders with icon 1`] = ` className="kbnPageTemplateSolutionNav" heading={ - - & { diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.ts b/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.ts deleted file mode 100644 index db2990726dc93..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { getClosestLink, hasActiveModifierKey } from '@kbn/shared-ux-utility'; - -interface CreateCrossAppClickHandlerOptions { - navigateToUrl(url: string): Promise; - container?: HTMLElement; -} - -export const createNavigateToUrlClickHandler = ({ - container, - navigateToUrl, -}: CreateCrossAppClickHandlerOptions): React.MouseEventHandler => { - return (e) => { - if (!container) { - return; - } - // see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239 - const target = e.target as HTMLElement; - - const link = getClosestLink(target, container); - if (!link) { - return; - } - - const isNotEmptyHref = link.href; - const hasNoTarget = link.target === '' || link.target === '_self'; - const isLeftClickOnly = e.button === 0; - - if ( - isNotEmptyHref && - hasNoTarget && - isLeftClickOnly && - !e.defaultPrevented && - !hasActiveModifierKey(e) - ) { - e.preventDefault(); - navigateToUrl(link.href); - } - }; -}; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts b/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts deleted file mode 100644 index db7462d7cb1bf..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts +++ /dev/null @@ -1,18 +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. - */ -/* eslint-disable import/no-default-export */ - -import { RedirectAppLinks } from './redirect_app_links'; -export type { RedirectAppLinksProps } from './redirect_app_links'; -export { RedirectAppLinks } from './redirect_app_links'; - -/** - * Exporting the RedirectAppLinks component as a default export so it can be - * loaded by React.lazy. - */ -export default RedirectAppLinks; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx deleted file mode 100644 index 0023182940ae9..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -id: sharedUX/Components/AppLink -slug: /shared-ux/components/redirect-app-link -title: Redirect App Link -summary: The component for redirect links. -tags: ['shared-ux', 'component'] -date: 2022-02-01 ---- - -> This documentation is in progress. - -**This component has been refactored.** Instead of requiring the entire `application`, it instead takes just `navigateToUrl` and `currentAppId$`. This makes the component more lightweight. diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx deleted file mode 100644 index 0ca0e2a8d9978..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx +++ /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 { EuiButton } from '@elastic/eui'; -import React from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { action } from '@storybook/addon-actions'; -import { RedirectAppLinks } from './redirect_app_links'; -import mdx from './redirect_app_links.mdx'; - -export default { - title: 'Redirect App Links', - description: 'app links component that takes in an application id and navigation url.', - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const Component = () => { - return ( - Promise.resolve()} - currentAppId$={new BehaviorSubject('test')} - > - - Test link - - - ); -}; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx deleted file mode 100644 index d36bace70b7c8..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { MouseEvent } from 'react'; -import { mount } from 'enzyme'; -import { BehaviorSubject } from 'rxjs'; - -import { RedirectAppLinks } from './redirect_app_links'; - -export type UnmountCallback = () => void; -export type MountPoint = (element: T) => UnmountCallback; - -const createServiceMock = () => { - const currentAppId$ = new BehaviorSubject('currentApp'); - - return { - currentAppId$: currentAppId$.asObservable(), - navigateToApp: jest.fn(), - navigateToUrl: jest.fn(), - }; -}; - -/* eslint-disable jsx-a11y/click-events-have-key-events */ - -describe('RedirectAppLinks', () => { - let application = createServiceMock(); - - beforeEach(() => { - application = createServiceMock(); - }); - - it('intercept click events on children link elements', () => { - let event: MouseEvent; - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - expect(application.navigateToUrl).toHaveBeenCalledTimes(1); - expect(event!.defaultPrevented).toBe(true); - }); - - it('intercept click events on children inside link elements', async () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToUrl).toHaveBeenCalledTimes(1); - expect(event!.defaultPrevented).toBe(true); - }); - - it('does not intercept click events when the target is not inside a link', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the link is a parent of the container', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the link has an external target', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the event is already defaultPrevented', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - e.preventDefault()}>content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(true); - }); - - it('does not intercept click events when the event propagation is stopped', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - e.stopPropagation()}> - content - - -
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!).toBe(undefined); - }); - - it('does not intercept click events when the event is not triggered from the left button', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 1, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the event has a modifier key enabled', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); -}); diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx deleted file mode 100644 index e1d0bd4bed653..0000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { useRef, useMemo } from 'react'; -import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import { Observable } from 'rxjs'; - -import { createNavigateToUrlClickHandler } from './click_handler'; - -type DivProps = DetailedHTMLProps, HTMLDivElement>; -/** - * TODO: this interface recreates props from the `ApplicationStart` interface. - * see: https://github.com/elastic/kibana/issues/127695 - */ -export interface RedirectAppLinksProps extends DivProps { - currentAppId$: Observable; - navigateToUrl(url: string): Promise; -} - -/** - * Utility component that will intercept click events on children anchor (``) elements to call - * `application.navigateToUrl` with the link's href. This will trigger SPA friendly navigation - * when the link points to a valid Kibana app. - * - * @example - * ```tsx - * url} currentAppId$={observableAppId}> - * Go to another-app - * - * ``` - * - * @remarks - * It is recommended to use the component at the highest possible level of the component tree that would - * require to handle the links. A good practice is to consider it as a context provider and to use it - * at the root level of an application or of the page that require the feature. - */ -export const RedirectAppLinks: FC = ({ - navigateToUrl, - currentAppId$, - children, - ...otherProps -}) => { - const currentAppId = useObservable(currentAppId$, undefined); - const containerRef = useRef(null); - const clickHandler = useMemo( - () => - containerRef.current && currentAppId - ? createNavigateToUrlClickHandler({ - container: containerRef.current, - navigateToUrl, - }) - : undefined, - [currentAppId, navigateToUrl] - ); - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
- {children} -
- ); -}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx deleted file mode 100644 index bc26806016df0..0000000000000 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { KibanaSolutionAvatar, KibanaSolutionAvatarProps } from './solution_avatar'; - -export default { - title: 'Solution Avatar', - description: 'A wrapper around EuiAvatar, specifically to stylize Elastic Solutions', -}; - -type Params = Pick; - -export const PureComponent = (params: Params) => { - return ; -}; - -PureComponent.argTypes = { - name: { - control: 'text', - defaultValue: 'Kibana', - }, - size: { - control: 'radio', - options: ['s', 'm', 'l', 'xl', 'xxl'], - defaultValue: 'xxl', - }, -}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx deleted file mode 100644 index deb71affc9c1a..0000000000000 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx +++ /dev/null @@ -1,44 +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 './solution_avatar.scss'; - -import React from 'react'; - -import { DistributiveOmit, EuiAvatar, EuiAvatarProps } from '@elastic/eui'; -import classNames from 'classnames'; - -export type KibanaSolutionAvatarProps = DistributiveOmit & { - /** - * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version - */ - size?: EuiAvatarProps['size'] | 'xxl'; -}; - -/** - * Applies extra styling to a typical EuiAvatar. - * The `name` value will be appended to 'logo' to configure the `iconType` unless `iconType` is provided. - */ -export const KibanaSolutionAvatar = ({ className, size, ...rest }: KibanaSolutionAvatarProps) => { - return ( - // @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine - - ); -}; diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap index c3b7dc63bce94..8091bd222d1a3 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap @@ -4,7 +4,9 @@ exports[` is rendered 1`] = ` is rendered 1`] = ` ({ navigateToUrl: () => Promise.resolve(), - currentAppId$: new Observable(), + currentAppId$: new Observable((subscriber) => { + subscriber.next('abc123'); + }), }); diff --git a/packages/kbn-shared-ux-storybook/src/services/application.ts b/packages/kbn-shared-ux-storybook/src/services/application.ts index 2a544445fc474..1b16526bc8be8 100644 --- a/packages/kbn-shared-ux-storybook/src/services/application.ts +++ b/packages/kbn-shared-ux-storybook/src/services/application.ts @@ -16,8 +16,8 @@ export type ApplicationServiceFactory = ServiceFactory ({ - navigateToUrl: () => { - action('NavigateToUrl'); + navigateToUrl: (url) => { + action('navigateToUrl')(url); return Promise.resolve(); }, currentAppId$: new BehaviorSubject('123'), diff --git a/packages/kbn-test-jest-helpers/BUILD.bazel b/packages/kbn-test-jest-helpers/BUILD.bazel index dc8b83495494c..85192829003e4 100644 --- a/packages/kbn-test-jest-helpers/BUILD.bazel +++ b/packages/kbn-test-jest-helpers/BUILD.bazel @@ -60,7 +60,6 @@ RUNTIME_DEPS = [ "@npm//joi", "@npm//mustache", "@npm//normalize-path", - "@npm//parse-link-header", "@npm//prettier", "@npm//react", "@npm//react-dom", @@ -106,7 +105,6 @@ TYPES_DEPS = [ "@npm//@types/mustache", "@npm//@types/normalize-path", "@npm//@types/node", - "@npm//@types/parse-link-header", "@npm//@types/prettier", "@npm//@types/react", "@npm//@types/react-dom", diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index f7599e6d81649..15487aa781b8d 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -67,7 +67,6 @@ RUNTIME_DEPS = [ "@npm//js-yaml", "@npm//mustache", "@npm//normalize-path", - "@npm//parse-link-header", "@npm//prettier", "@npm//react-dom", "@npm//react-redux", @@ -115,7 +114,6 @@ TYPES_DEPS = [ "@npm//@types/mustache", "@npm//@types/normalize-path", "@npm//@types/node", - "@npm//@types/parse-link-header", "@npm//@types/prettier", "@npm//@types/react-dom", "@npm//@types/react-redux", diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 42dc19445c293..c065cb01a4c36 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -146,6 +146,11 @@ export interface CreateTestEsClusterOptions { * defaults to the transport port from `packages/kbn-test/src/es/es_test_config.ts` */ transportPort?: number | string; + /** + * Report to the creator of the es-test-cluster that the es node has exitted before stop() was called, allowing + * this caller to react appropriately. If this is not passed then an uncatchable exception will be thrown + */ + onEarlyExit?: (msg: string) => void; } export function createTestEsCluster< @@ -165,6 +170,7 @@ export function createTestEsCluster< clusterName: customClusterName = 'es-test-cluster', ssl, transportPort, + onEarlyExit, } = options; const clusterName = `${CI_PARALLEL_PROCESS_PREFIX}${customClusterName}`; @@ -258,6 +264,7 @@ export function createTestEsCluster< // set it up after the last node is started. skipNativeRealmSetup: this.nodes.length > 1 && i < this.nodes.length - 1, skipReadyCheck: this.nodes.length > 1 && i < this.nodes.length - 1, + onEarlyExit, }); }); } diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 4159533e628bc..f71e4ac7d6ccd 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { resolve } from 'path'; +import Path from 'path'; import { inspect } from 'util'; import { run, createFlagError, Flags } from '@kbn/dev-utils'; @@ -16,7 +16,7 @@ import exitHook from 'exit-hook'; import { FunctionalTestRunner } from './functional_test_runner'; -const makeAbsolutePath = (v: string) => resolve(process.cwd(), v); +const makeAbsolutePath = (v: string) => Path.resolve(process.cwd(), v); const toArray = (v: string | string[]) => ([] as string[]).concat(v || []); const parseInstallDir = (flags: Flags) => { const flag = flags['kibana-install-dir']; @@ -42,9 +42,15 @@ export function runFtrCli() { throw createFlagError('expected --es-version to be a string'); } + const configRel = flags.config; + if (typeof configRel !== 'string' || !configRel) { + throw createFlagError('--config is required'); + } + const configPath = makeAbsolutePath(configRel); + const functionalTestRunner = new FunctionalTestRunner( log, - makeAbsolutePath(flags.config as string), + configPath, { mochaOpts: { bail: flags.bail, @@ -69,6 +75,8 @@ export function runFtrCli() { esVersion ); + await functionalTestRunner.readConfigFile(); + if (flags.throttle) { process.env.TEST_THROTTLE_NETWORK = '1'; } @@ -149,9 +157,6 @@ export function runFtrCli() { 'headless', 'dry-run', ], - default: { - config: 'test/functional/config.js', - }, help: ` --config=path path to a config file --bail stop tests after the first failure diff --git a/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts b/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts index 96ebcd79c4e43..506b6f139f736 100644 --- a/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts +++ b/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts @@ -37,6 +37,7 @@ export interface Test { export interface Runner extends EventEmitter { abort(): void; failures: any[]; + uncaught: (error: Error) => void; } export interface Mocha { diff --git a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts index 0ceba511f9b9b..9de6500a45323 100644 --- a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts +++ b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts @@ -43,7 +43,7 @@ export class FunctionalTestRunner { : new EsVersion(esVersion); } - async run() { + async run(abortSignal?: AbortSignal) { const testStats = await this.getTestStats(); return await this.runHarness(async (config, lifecycle, coreProviders) => { @@ -106,10 +106,19 @@ export class FunctionalTestRunner { return this.simulateMochaDryRun(mocha); } + if (abortSignal?.aborted) { + this.log.warning('run aborted'); + return; + } + await lifecycle.beforeTests.trigger(mocha.suite); - this.log.info('Starting tests'); + if (abortSignal?.aborted) { + this.log.warning('run aborted'); + return; + } - return await runTests(lifecycle, mocha); + this.log.info('Starting tests'); + return await runTests(lifecycle, mocha, abortSignal); }); } @@ -210,12 +219,7 @@ export class FunctionalTestRunner { const lifecycle = new Lifecycle(this.log); try { - const config = await readConfigFile( - this.log, - this.esVersion, - this.configFile, - this.configOverrides - ); + const config = await this.readConfigFile(); this.log.debug('Config loaded'); if ( @@ -259,6 +263,10 @@ export class FunctionalTestRunner { } } + public async readConfigFile() { + return await readConfigFile(this.log, this.esVersion, this.configFile, this.configOverrides); + } + simulateMochaDryRun(mocha: any) { interface TestEntry { file: string; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts index 24702d699064c..49a6ef16d6685 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts @@ -10,6 +10,7 @@ import Path from 'path'; import { ToolingLog } from '@kbn/tooling-log'; import { defaultsDeep } from 'lodash'; import { createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Config } from './config'; import { EsVersion } from '../es_version'; @@ -26,21 +27,33 @@ async function getSettingsFromFile( primary: boolean; } ) { + let resolvedPath; + try { + resolvedPath = require.resolve(options.path); + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + throw createFlagError(`Unable to find config file [${options.path}]`); + } + + throw error; + } + if ( options.primary && - !FTR_CONFIGS_MANIFEST_PATHS.includes(options.path) && - !options.path.includes(`${Path.sep}__fixtures__${Path.sep}`) + !FTR_CONFIGS_MANIFEST_PATHS.includes(resolvedPath) && + !resolvedPath.includes(`${Path.sep}__fixtures__${Path.sep}`) ) { + const rel = Path.relative(REPO_ROOT, resolvedPath); throw createFlagError( - `Refusing to load FTR Config which is not listed in [${FTR_CONFIGS_MANIFEST_REL}]. All FTR Config files must be listed there, use the "enabled" key if the FTR Config should be run on automatically on PR CI, or the "disabled" key if it is run manually or by a special job.` + `Refusing to load FTR Config at [${rel}] which is not listed in [${FTR_CONFIGS_MANIFEST_REL}]. All FTR Config files must be listed there, use the "enabled" key if the FTR Config should be run on automatically on PR CI, or the "disabled" key if it is run manually or by a special job.` ); } - const configModule = require(options.path); // eslint-disable-line @typescript-eslint/no-var-requires + const configModule = require(resolvedPath); // eslint-disable-line @typescript-eslint/no-var-requires const configProvider = configModule.__esModule ? configModule.default : configModule; if (!cache.has(configProvider)) { - log.debug('Loading config file from %j', options.path); + log.debug('Loading config file from %j', resolvedPath); cache.set( configProvider, configProvider({ diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts b/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts index 89f0ea088cac8..12840b77dd8d9 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import * as Rx from 'rxjs'; import { Lifecycle } from '../lifecycle'; import { Mocha } from '../../fake_mocha_types'; @@ -18,14 +19,23 @@ import { Mocha } from '../../fake_mocha_types'; * @param {Mocha} mocha * @return {Promise} resolves to the number of test failures */ -export async function runTests(lifecycle: Lifecycle, mocha: Mocha) { +export async function runTests(lifecycle: Lifecycle, mocha: Mocha, abortSignal?: AbortSignal) { let runComplete = false; const runner = mocha.run(() => { runComplete = true; }); - lifecycle.cleanup.add(() => { - if (!runComplete) runner.abort(); + Rx.race( + lifecycle.cleanup.before$, + abortSignal ? Rx.fromEvent(abortSignal, 'abort').pipe(Rx.take(1)) : Rx.NEVER + ).subscribe({ + next() { + if (!runComplete) { + runComplete = true; + runner.uncaught(new Error('Forcing mocha to abort')); + runner.abort(); + } + }, }); return new Promise((resolve) => { diff --git a/packages/kbn-test/src/functional_tests/lib/index.ts b/packages/kbn-test/src/functional_tests/lib/index.ts index bf2cc43159526..2726192328bda 100644 --- a/packages/kbn-test/src/functional_tests/lib/index.ts +++ b/packages/kbn-test/src/functional_tests/lib/index.ts @@ -10,5 +10,5 @@ export { runKibanaServer } from './run_kibana_server'; export { runElasticsearch } from './run_elasticsearch'; export type { CreateFtrOptions, CreateFtrParams } from './run_ftr'; export { runFtr, hasTests, assertNoneExcluded } from './run_ftr'; -export { KIBANA_ROOT, KIBANA_FTR_SCRIPT, FUNCTIONAL_CONFIG_PATH, API_CONFIG_PATH } from './paths'; +export { KIBANA_ROOT, KIBANA_FTR_SCRIPT } from './paths'; export { runCli } from './run_cli'; diff --git a/packages/kbn-test/src/functional_tests/lib/paths.ts b/packages/kbn-test/src/functional_tests/lib/paths.ts index 37cd708de1e00..75a654fdfc513 100644 --- a/packages/kbn-test/src/functional_tests/lib/paths.ts +++ b/packages/kbn-test/src/functional_tests/lib/paths.ts @@ -19,6 +19,3 @@ export const KIBANA_EXEC = 'node'; export const KIBANA_EXEC_PATH = resolveRelative('scripts/kibana'); export const KIBANA_ROOT = REPO_ROOT; export const KIBANA_FTR_SCRIPT = resolve(KIBANA_ROOT, 'scripts/functional_test_runner'); -export const PROJECT_ROOT = resolve(__dirname, '../../../../../../'); -export const FUNCTIONAL_CONFIG_PATH = resolve(KIBANA_ROOT, 'test/functional/config'); -export const API_CONFIG_PATH = resolve(KIBANA_ROOT, 'test/api_integration/config'); diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index adbb18b5312d0..2ee9de4053fef 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -17,6 +17,7 @@ interface RunElasticsearchOptions { log: ToolingLog; esFrom?: string; config: Config; + onEarlyExit?: (msg: string) => void; } interface CcsConfig { @@ -92,7 +93,8 @@ export async function runElasticsearch( async function startEsNode( log: ToolingLog, name: string, - config: EsConfig & { transportPort?: number } + config: EsConfig & { transportPort?: number }, + onEarlyExit?: (msg: string) => void ) { const cluster = createTestEsCluster({ clusterName: `cluster-${name}`, @@ -112,6 +114,7 @@ async function startEsNode( }, ], transportPort: config.transportPort, + onEarlyExit, }); await cluster.start(); diff --git a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts index 4c4a7128a05a9..b9945adbdfb56 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts @@ -81,8 +81,8 @@ async function createFtr({ }; } -export async function assertNoneExcluded({ configPath, options }: CreateFtrParams) { - const { config, ftr } = await createFtr({ configPath, options }); +export async function assertNoneExcluded(params: CreateFtrParams) { + const { config, ftr } = await createFtr(params); if (config.get('testRunner')) { // tests with custom test runners are not included in this check @@ -95,21 +95,21 @@ export async function assertNoneExcluded({ configPath, options }: CreateFtrParam } if (stats.testsExcludedByTag.length > 0) { throw new CliError(` - ${stats.testsExcludedByTag.length} tests in the ${configPath} config + ${stats.testsExcludedByTag.length} tests in the ${params.configPath} config are excluded when filtering by the tags run on CI. Make sure that all suites are tagged with one of the following tags: - ${JSON.stringify(options.suiteTags)} + ${JSON.stringify(params.options.suiteTags)} - ${stats.testsExcludedByTag.join('\n - ')} `); } } -export async function runFtr({ configPath, options }: CreateFtrParams) { - const { ftr } = await createFtr({ configPath, options }); +export async function runFtr(params: CreateFtrParams, signal?: AbortSignal) { + const { ftr } = await createFtr(params); - const failureCount = await ftr.run(); + const failureCount = await ftr.run(signal); if (failureCount > 0) { throw new CliError( `${failureCount} functional test ${failureCount === 1 ? 'failure' : 'failures'}` @@ -117,8 +117,8 @@ export async function runFtr({ configPath, options }: CreateFtrParams) { } } -export async function hasTests({ configPath, options }: CreateFtrParams) { - const { ftr, config } = await createFtr({ configPath, options }); +export async function hasTests(params: CreateFtrParams) { + const { ftr, config } = await createFtr(params); if (config.get('testRunner')) { // configs with custom test runners are assumed to always have tests diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts index 47d0b1c93b620..b5026d397139d 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts @@ -31,10 +31,12 @@ export async function runKibanaServer({ procs, config, options, + onEarlyExit, }: { procs: ProcRunner; config: Config; options: { installDir?: string; extraKbnOpts?: string[] }; + onEarlyExit?: (msg: string) => void; }) { const runOptions = config.get('kbnTestServer.runOptions'); const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; @@ -51,6 +53,7 @@ export async function runKibanaServer({ }, cwd: installDir || KIBANA_ROOT, wait: runOptions.wait, + onEarlyExit, }); } diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index dd9fe4c93016c..33a49ae2c80d1 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -107,14 +107,26 @@ export async function runTests(options: RunTestsParams) { await withProcRunner(log, async (procs) => { const config = await readConfigFile(log, options.esVersion, configPath); + const abortCtrl = new AbortController(); + + const onEarlyExit = (msg: string) => { + log.error(msg); + abortCtrl.abort(); + }; let shutdownEs; try { if (process.env.TEST_ES_DISABLE_STARTUP !== 'true') { - shutdownEs = await runElasticsearch({ ...options, log, config }); + shutdownEs = await runElasticsearch({ ...options, log, config, onEarlyExit }); + if (abortCtrl.signal.aborted) { + return; + } + } + await runKibanaServer({ procs, config, options, onEarlyExit }); + if (abortCtrl.signal.aborted) { + return; } - await runKibanaServer({ procs, config, options }); - await runFtr({ configPath, options: { ...options, log } }); + await runFtr({ configPath, options: { ...options, log } }, abortCtrl.signal); } finally { try { const delay = config.get('kbnTestServer.delayShutdown'); diff --git a/packages/shared-ux/avatar/solution/BUILD.bazel b/packages/shared-ux/avatar/solution/BUILD.bazel new file mode 100644 index 0000000000000..a253153cb9227 --- /dev/null +++ b/packages/shared-ux/avatar/solution/BUILD.bazel @@ -0,0 +1,146 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "solution" +PKG_REQUIRE_NAME = "@kbn/shared-ux-avatar-solution" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.scss", + "src/**/*.mdx", + "src/**/*.svg", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//classnames", + "@npm//enzyme", + "@npm//react", + "@npm//url-loader", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//@types/classnames", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/avatar/solution/README.mdx b/packages/shared-ux/avatar/solution/README.mdx new file mode 100644 index 0000000000000..841274441f6ed --- /dev/null +++ b/packages/shared-ux/avatar/solution/README.mdx @@ -0,0 +1,26 @@ +--- +id: sharedUX/Components/KibanaSolutionAvatar +slug: /shared-ux/components/avatar-solution +title: Solution Avatar +summary: A wrapper around `EuiAvatar` tailored for use in Kibana solutions. +tags: ['shared-ux', 'component'] +date: 2022-05-04 +--- + +## Description + +A wrapper around `EuiAvatar` tailored for use in Kibana solutions. + +## Usage + +If using for a known solution, (e.g. one whose logo is in EUI as `logoSomeSolution`), you can simply set the `name` prop: + +```tsx + +``` + +If the name provided does not match a known solution, you *must* set the `iconType` prop: + +```tsx + +``` diff --git a/packages/shared-ux/avatar/solution/jest.config.js b/packages/shared-ux/avatar/solution/jest.config.js new file mode 100644 index 0000000000000..6ca49f67e1dd5 --- /dev/null +++ b/packages/shared-ux/avatar/solution/jest.config.js @@ -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 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/avatar/solution'], +}; diff --git a/packages/shared-ux/avatar/solution/package.json b/packages/shared-ux/avatar/solution/package.json new file mode 100644 index 0000000000000..b0ec8ec947b09 --- /dev/null +++ b/packages/shared-ux/avatar/solution/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-avatar-solution", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap b/packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap similarity index 54% rename from packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap rename to packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap index 9817d7cdd8d45..f0666987e0f79 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap +++ b/packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap @@ -8,3 +8,12 @@ exports[`KibanaSolutionAvatar renders 1`] = ` name="Solution" /> `; + +exports[`KibanaSolutionAvatar renders 2`] = ` + +`; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg b/packages/shared-ux/avatar/solution/src/assets/texture.svg similarity index 100% rename from packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg rename to packages/shared-ux/avatar/solution/src/assets/texture.svg diff --git a/packages/shared-ux/avatar/solution/src/index.tsx b/packages/shared-ux/avatar/solution/src/index.tsx new file mode 100644 index 0000000000000..c2c9613bab87d --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { withSuspense } from '@kbn/shared-ux-utility'; + +export type { KibanaSolutionAvatarProps } from './solution_avatar'; + +/** + * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or + * the withSuspense` HOC to load this component. + */ +export const KibanaSolutionAvatarLazy = React.lazy(() => + import('./solution_avatar').then(({ KibanaSolutionAvatar }) => ({ + default: KibanaSolutionAvatar, + })) +); + +/** + * A `KibanaSolutionAvatar` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavAvatarLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const KibanaSolutionAvatar = withSuspense(KibanaSolutionAvatarLazy); diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss b/packages/shared-ux/avatar/solution/src/solution_avatar.scss similarity index 100% rename from packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss rename to packages/shared-ux/avatar/solution/src/solution_avatar.scss diff --git a/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx new file mode 100644 index 0000000000000..b47ff7c837f24 --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { KibanaSolutionAvatar, IconTypeProps, KnownSolutionProps } from './solution_avatar'; + +export default { + title: 'Solution Avatar', + description: 'A wrapper around EuiAvatar, specifically to stylize Elastic Solutions', +}; + +const argTypes = { + size: { + control: 'select', + options: ['s', 'm', 'l', 'xl', 'xxl'], + defaultValue: 'xxl', + }, +}; + +type KnownSolutionParams = Pick; + +export const SolutionAvatar = (params: KnownSolutionParams) => { + return ; +}; + +SolutionAvatar.argTypes = { + name: { + control: 'select', + options: ['Cloud', 'Elastic', 'Kibana', 'Observability', 'Security', 'Enterprise Search'], + defaultValue: 'Elastic', + }, + ...argTypes, +}; + +type IconTypeParams = Pick; + +export const IconTypeAvatar = (params: IconTypeParams) => { + return ; +}; + +IconTypeAvatar.argTypes = { + iconType: { + control: 'select', + options: [ + 'logoCloud', + 'logoElastic', + 'logoElasticsearch', + 'logoElasticStack', + 'logoKibana', + 'logoObservability', + 'logoSecurity', + 'logoSiteSearch', + 'logoWorkplaceSearch', + 'machineLearningApp', + 'managementApp', + ], + defaultValue: 'logoElastic', + }, + name: { + control: 'text', + defaultValue: 'Solution Name', + }, + ...argTypes, +}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx similarity index 68% rename from packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx rename to packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx index 7a8b20c3f8d64..ab7c675b24e0d 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx @@ -12,7 +12,9 @@ import { KibanaSolutionAvatar } from './solution_avatar'; describe('KibanaSolutionAvatar', () => { test('renders', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); + const nameAndIcon = shallow(); + expect(nameAndIcon).toMatchSnapshot(); + const nameOnly = shallow(); + expect(nameOnly).toMatchSnapshot(); }); }); diff --git a/packages/shared-ux/avatar/solution/src/solution_avatar.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.tsx new file mode 100644 index 0000000000000..0c38652a27395 --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 './solution_avatar.scss'; + +import React from 'react'; +import classNames from 'classnames'; + +import { DistributiveOmit, EuiAvatar, EuiAvatarProps, IconType } from '@elastic/eui'; + +import { SolutionNameType } from './types'; + +export type KnownSolutionProps = DistributiveOmit & { + /** + * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version + */ + size?: EuiAvatarProps['size'] | 'xxl'; + name: SolutionNameType; +}; + +export type IconTypeProps = DistributiveOmit & { + /** + * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version + */ + size?: EuiAvatarProps['size'] | 'xxl'; + name?: string; + iconType: IconType; +}; + +const isKnown = (props: any): props is KnownSolutionProps => { + return typeof props.iconType === 'undefined'; +}; + +export type KibanaSolutionAvatarProps = KnownSolutionProps | IconTypeProps; + +/** + * Applies extra styling to a typical EuiAvatar. + * The `name` value will be appended to 'logo' to configure the `iconType` unless `iconType` is provided. + */ +export const KibanaSolutionAvatar = (props: KibanaSolutionAvatarProps) => { + const { className, size, ...rest } = props; + + // If the name is a known solution, use the name to set the correct IconType. + // Create an empty object so `iconType` remains undefined or inherited from `props`. + const icon: { + iconType?: IconType; + } = {}; + + if (isKnown(props)) { + icon.iconType = `logo${props.name.replace(/\s+/g, '')}`; + } + + return ( + // @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine + + ); +}; diff --git a/packages/shared-ux/avatar/solution/src/types.ts b/packages/shared-ux/avatar/solution/src/types.ts new file mode 100644 index 0000000000000..bf0ad682e3006 --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +// Manual, exhaustive list at present. This was attempted dynamically using Typescript Template Literals and +// the computation cost exceeded the benefit. By enumerating them manually, we reduce the complexity of TS +// checking at the expense of not being dynamic against a very, very static list. +// +// The only consequence is requiring a solution name without a space, (e.g. `ElasticStack`) until it's added +// here. That's easy to do in the very unlikely event that ever happens. +export type SolutionNameType = + | 'App Search' + | 'Beats' + | 'Business Analytics' + | 'Cloud' + | 'Cloud Enterprise' + | 'Code' + | 'Elastic' + | 'Elastic Stack' + | 'Elasticsearch' + | 'Enterprise Search' + | 'Logstash' + | 'Maps' + | 'Metrics' + | 'Observability' + | 'Security' + | 'Site Search' + | 'Uptime' + | 'Webhook' + | 'Workplace Search'; diff --git a/packages/shared-ux/avatar/solution/tsconfig.json b/packages/shared-ux/avatar/solution/tsconfig.json new file mode 100644 index 0000000000000..93076efae5d7c --- /dev/null +++ b/packages/shared-ux/avatar/solution/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/shared-ux/link/redirect_app/BUILD.bazel b/packages/shared-ux/link/redirect_app/BUILD.bazel new file mode 100644 index 0000000000000..861b9aa277db9 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/BUILD.bazel @@ -0,0 +1,140 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "redirect_app" +PKG_REQUIRE_NAME = "@kbn/shared-ux-link-redirect-app" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//react-use", + "@npm//react", + "@npm//rxjs", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "@npm//rxjs", + "@npm//react-use", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/link/redirect_app/README.mdx b/packages/shared-ux/link/redirect_app/README.mdx new file mode 100644 index 0000000000000..8e2eada760ea2 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/README.mdx @@ -0,0 +1,86 @@ +--- +id: sharedUX/Components/AppLink +slug: /shared-ux/components/redirect-app-links +title: Redirect App Links +summary: A component for redirecting links contained within it to the appropriate Kibana solution without a page refresh. +tags: ['shared-ux', 'component'] +date: 2022-05-04 +--- + +## Description + +This component is an "area of effect" component, which produces a container that intercepts actions for specific elements within it. In this case, the container intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh. + +## Pure Component + +The pure component allows you create a container to intercept clicks without contextual services, (e.g. Kibana Core). This likely does not have much utility for solutions in Kibana, but rather is useful for shared components where we want to ensure clicks are redirected correctly. + +```tsx +import { RedirectAppLinksComponent as RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + Go to another-app + +``` + +## Connected Component + +The connected component uses a React Context to access services that provide the current app id and a function to navigate to a new url. This is useful in that a solution can wrap their entire application in the context and use `RedirectAppLinks` in specific areas. + +```tsx +import { RedirectAppLinksContainer as RedirectAppLinks, RedirectAppLinksProvider } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + . + {/* other components that don't need to redirect */} + . + + Go to another-app + + . + . + . + +``` + +You can also use the Kibana provider: + +```tsx +import { + RedirectAppLinksContainer as RedirectAppLinks, + RedirectAppLinksKibanaProvider as RedirectAppLinksProvider +} from '@kbn/shared-ux-links-redirect-app'; + + + . + {/* other components that don't need to redirect */} + . + + Go to another-app + + . + . + +``` + +## Top-level Component + +This is the component is likely the most useful to solutions in Kibana. It assumes an entire solution needs this redirect functionality, and combines the context provider with the container. This top-level component can be used with either pure props or with Kibana services. + +```tsx +import { RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + . + Go to another-app + . + + +{/* OR */} + + + . + Go to another-app + . + +``` \ No newline at end of file diff --git a/packages/shared-ux/link/redirect_app/jest.config.js b/packages/shared-ux/link/redirect_app/jest.config.js new file mode 100644 index 0000000000000..5f564a9709d0c --- /dev/null +++ b/packages/shared-ux/link/redirect_app/jest.config.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 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/link/redirect_app'], + verbose: true, +}; diff --git a/packages/shared-ux/link/redirect_app/package.json b/packages/shared-ux/link/redirect_app/package.json new file mode 100644 index 0000000000000..6deb187dcec2a --- /dev/null +++ b/packages/shared-ux/link/redirect_app/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-link-redirect-app", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts b/packages/shared-ux/link/redirect_app/src/click_handler.test.ts similarity index 84% rename from packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts rename to packages/shared-ux/link/redirect_app/src/click_handler.test.ts index dd26443eed171..c46b93bb67aaf 100644 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts +++ b/packages/shared-ux/link/redirect_app/src/click_handler.test.ts @@ -7,7 +7,7 @@ */ import { MouseEvent } from 'react'; -import { createNavigateToUrlClickHandler } from './click_handler'; +import { navigateToUrlClickHandler } from './click_handler'; const createLink = ({ href = '/base-path/app/targetApp', @@ -43,27 +43,59 @@ const createEvent = ({ type NavigateToURLFn = (url: string) => Promise; -describe('createNavigateToUrlClickHandler', () => { +describe('navigateToUrlClickHandler', () => { let container: HTMLElement; let navigateToUrl: jest.MockedFunction; + const currentAppId = 'abc123'; - const createHandler = () => - createNavigateToUrlClickHandler({ + const handler = (event: MouseEvent): void => { + navigateToUrlClickHandler({ + event, + currentAppId, container, navigateToUrl, }); + }; beforeEach(() => { container = document.createElement('div'); navigateToUrl = jest.fn(); }); - it('calls `navigateToUrl` with the link url', () => { - const handler = createHandler(); + it("doesn't call `navigateToUrl` without a container", () => { + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + navigateToUrlClickHandler({ + event, + currentAppId, + container: null, + navigateToUrl, + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); + + it("doesn't call `navigateToUrl` without a `currentAppId`", () => { const event = createEvent({ target: createLink({ href: '/base-path/app/targetApp' }), }); + + navigateToUrlClickHandler({ + event, + container, + navigateToUrl, + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); + + it('calls `navigateToUrl` with the link url', () => { + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -71,13 +103,12 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is triggered if a non-link target has a parent link', () => { - const handler = createHandler(); - const link = createLink(); const target = document.createElement('span'); link.appendChild(target); const event = createEvent({ target }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -85,13 +116,12 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if a non-link target has no parent link', () => { - const handler = createHandler(); - const parent = document.createElement('div'); const target = document.createElement('span'); parent.appendChild(target); const event = createEvent({ target }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -99,11 +129,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered when the link has no href', () => { - const handler = createHandler(); - const event = createEvent({ target: createLink({ href: '' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -111,11 +140,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is only triggered when the link does not have an external target', () => { - const handler = createHandler(); - let event = createEvent({ target: createLink({ target: '_blank' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -124,6 +152,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: 'some-target' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -132,6 +161,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: '_self' }), }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -140,6 +170,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: '' }), }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -147,11 +178,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is only triggered from left clicks', () => { - const handler = createHandler(); - let event = createEvent({ button: 1, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -160,6 +190,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ button: 12, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -168,6 +199,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ button: 0, }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -175,11 +207,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if the event default is prevented', () => { - const handler = createHandler(); - let event = createEvent({ defaultPrevented: true, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -188,6 +219,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ defaultPrevented: false, }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -195,15 +227,15 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if any modifier key is pressed', () => { - const handler = createHandler(); - let event = createEvent({ modifierKey: true }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(navigateToUrl).not.toHaveBeenCalled(); event = createEvent({ modifierKey: false }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); diff --git a/packages/shared-ux/link/redirect_app/src/click_handler.ts b/packages/shared-ux/link/redirect_app/src/click_handler.ts new file mode 100644 index 0000000000000..8c94aa0033f2b --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/click_handler.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 { MouseEvent } from 'react'; +import { getClosestLink, hasActiveModifierKey } from '@kbn/shared-ux-utility'; +import { NavigateToUrl } from './types'; + +interface CreateCrossAppClickHandlerOptions { + event: MouseEvent; + navigateToUrl: NavigateToUrl; + container: HTMLElement | null; + currentAppId?: string; +} + +/** + * Constructs a click handler that will redirect the user using `navigateToUrl` if the + * correct conditions are met. + */ +export const navigateToUrlClickHandler = ({ + event, + container, + navigateToUrl, + currentAppId, +}: CreateCrossAppClickHandlerOptions) => { + if (!container || !currentAppId) { + return; + } + + // see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239 + const target = event.target as HTMLElement; + + const link = getClosestLink(target, container); + + if (!link) { + return; + } + + const isNotEmptyHref = link.href; + const hasNoTarget = link.target === '' || link.target === '_self'; + const isLeftClickOnly = event.button === 0; + + if ( + isNotEmptyHref && + hasNoTarget && + isLeftClickOnly && + !event.defaultPrevented && + !hasActiveModifierKey(event) + ) { + event.preventDefault(); + navigateToUrl(link.href); + } +}; diff --git a/packages/shared-ux/link/redirect_app/src/index.tsx b/packages/shared-ux/link/redirect_app/src/index.tsx new file mode 100644 index 0000000000000..5efb99cc48664 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links'; +export { RedirectAppLinks as RedirectAppLinksComponent } from './redirect_app_links'; +export { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services'; + +import React, { FC } from 'react'; +import { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links'; +import { + Services, + KibanaServices, + RedirectAppLinksKibanaProvider, + RedirectAppLinksProvider, +} from './services'; + +const isKibanaContract = (services: any): services is KibanaServices => { + return typeof services.coreStart !== 'undefined'; +}; + +/** + * This component composes `RedirectAppLinksContainer` with either `RedirectAppLinksProvider` or + * `RedirectAppLinksKibanaProvider` based on the services provided, creating a single component + * with which consumers can wrap their components or solutions. + */ +export const RedirectAppLinks: FC = ({ children, ...services }) => { + const container = {children}; + + return isKibanaContract(services) ? ( + {container} + ) : ( + {container} + ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.tsx new file mode 100644 index 0000000000000..477471fe71824 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.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 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, { useRef, MouseEventHandler, useCallback } from 'react'; +import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react'; + +import { navigateToUrlClickHandler } from './click_handler'; +import { NavigateToUrl } from './types'; + +export interface Props extends DetailedHTMLProps, HTMLDivElement> { + navigateToUrl: NavigateToUrl; + currentAppId?: string | undefined; +} + +/** + * Utility component that will intercept click events on children anchor (``) elements to call + * `navigateToUrl` with the link's href. This will trigger SPA friendly navigation when the link points + * to a valid Kibana app. + * + * @example + * ```tsx + * { ... }}> + * Go to another-app + * + * ``` + */ +export const RedirectAppLinks: FC = ({ + children, + navigateToUrl, + currentAppId, + ...otherProps +}) => { + const containerRef = useRef(null); + + const handleClick: MouseEventHandler = useCallback( + (event) => + navigateToUrlClickHandler({ + event, + currentAppId, + navigateToUrl, + container: containerRef.current, + }), + [currentAppId, navigateToUrl] + ); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
+ {children} +
+ ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx new file mode 100644 index 0000000000000..9bb3d0d9782d4 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; + +import { action } from '@storybook/addon-actions'; +import { RedirectAppLinks } from '.'; +import mdx from '../README.mdx'; + +export default { + title: 'Redirect App Links', + description: + 'An "area of effect" component which intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const Component = () => { + const navigateToUrl = async (url: string) => { + action('navigateToUrl')(url); + }; + + const currentAppId = 'abc123'; + + return ( + <> + + + + + Button with URL + + + + + Button without URL + + + + + + + + Button outside RedirectAppLinks + + + + + ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx new file mode 100644 index 0000000000000..1bb3875aec7ae --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx @@ -0,0 +1,292 @@ +/* + * Copyright 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. + */ + +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import React, { MouseEvent } from 'react'; +import { mount as enzymeMount, ReactWrapper } from 'enzyme'; + +import { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services'; +import { RedirectAppLinks } from './redirect_app_links'; +import { RedirectAppLinks as ComposedWrapper } from '.'; +import { Observable } from 'rxjs'; + +export type UnmountCallback = () => void; +export type MountPoint = (element: T) => UnmountCallback; +type Mount = ( + node: React.ReactElement +) => ReactWrapper, React.Component<{}, {}, any>>; + +const commonTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => { + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + describe(`RedirectAppLinks with ${name}`, () => { + it('intercept click events on children link elements', () => { + let event: MouseEvent; + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('intercept click events on children inside link elements', async () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the target is not inside a link', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the link has an external target', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event is already defaultPrevented', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + e.preventDefault()}>content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the event propagation is stopped', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + e.stopPropagation()}> + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!).toBe(undefined); + }); + + it('does not intercept click events when the event is not triggered from the left button', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 1, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event has a modifier key enabled', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + }); +}; + +const targetedTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => { + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + describe(`${name} with isolated areas of effect`, () => { + it(`does not intercept click events when the link is a parent of the container`, () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + }); +}; + +describe('RedirectAppLinks', () => { + const navigateToUrl = jest.fn(); + + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + const kibana = { + coreStart: { + application: { + currentAppId$: new Observable((subscriber) => { + subscriber.next('123'); + }), + navigateToUrl, + }, + }, + }; + + const services = { + currentAppId: 'abc123', + navigateToUrl, + }; + + const provider = (node: React.ReactElement) => + enzymeMount({node}); + + const kibanaProvider = (node: React.ReactElement) => + enzymeMount( + {node} + ); + + const composedProvider = (node: React.ReactElement) => + enzymeMount({node}); + + const composedKibanaProvider = (node: React.ReactElement) => + enzymeMount({node}); + + describe('Test all Providers', () => { + commonTests('RedirectAppLinksProvider', provider, navigateToUrl); + targetedTests('RedirectAppLinksProvider', provider, navigateToUrl); + commonTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl); + targetedTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl); + commonTests('Provider Props', composedProvider, navigateToUrl); + commonTests('Kibana Props', composedKibanaProvider, navigateToUrl); + }); +}); diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx new file mode 100644 index 0000000000000..1e805ad4475b6 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { useServices } from './services'; +import { + RedirectAppLinks as Component, + Props as ComponentProps, +} from './redirect_app_links.component'; + +type Props = Omit; + +/** + * A service-enabled component that provides Kibana-specific functionality to the `RedirectAppLinks` + * pure component. + * + * @example + * ```tsx + * + * Go to another-app + * + * ``` + */ +export const RedirectAppLinks = (props: Props) => ; diff --git a/packages/shared-ux/link/redirect_app/src/services.tsx b/packages/shared-ux/link/redirect_app/src/services.tsx new file mode 100644 index 0000000000000..22bc5a5cd0c55 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/services.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 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, { FC, useContext } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { NavigateToUrl } from './types'; + +/** + * Contextual services for this component. + */ +export interface Services { + navigateToUrl: NavigateToUrl; + currentAppId?: string; +} + +const RedirectAppLinksContext = React.createContext(null); + +/** + * Contextual services Provider. + */ +export const RedirectAppLinksProvider: FC = ({ children, ...services }) => { + return ( + + {children} + + ); +}; + +/** + * Kibana-specific contextual services to be adapted for this component. + */ +export interface KibanaServices { + coreStart: { + application: { + currentAppId$: Observable; + navigateToUrl: NavigateToUrl; + }; + }; +} + +/** + * Kibana-specific contextual services Provider. + */ +export const RedirectAppLinksKibanaProvider: FC = ({ children, coreStart }) => { + const { navigateToUrl, currentAppId$ } = coreStart.application; + const currentAppId = useObservable(currentAppId$, undefined); + + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(RedirectAppLinksContext); + + if (!context) { + throw new Error( + 'RedirectAppLinksContext is missing. Ensure your component or React root is wrapped with RedirectAppLinksProvider.' + ); + } + + return context; +} diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx b/packages/shared-ux/link/redirect_app/src/types.ts similarity index 73% rename from packages/kbn-shared-ux-components/src/solution_avatar/index.tsx rename to packages/shared-ux/link/redirect_app/src/types.ts index efc597cbdcb13..2c27ccde84d67 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx +++ b/packages/shared-ux/link/redirect_app/src/types.ts @@ -5,5 +5,5 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -export { KibanaSolutionAvatar } from './solution_avatar'; -export type { KibanaSolutionAvatarProps } from './solution_avatar'; + +export type NavigateToUrl = (url: string) => Promise | void; diff --git a/packages/shared-ux/link/redirect_app/tsconfig.json b/packages/shared-ux/link/redirect_app/tsconfig.json new file mode 100644 index 0000000000000..93076efae5d7c --- /dev/null +++ b/packages/shared-ux/link/redirect_app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/shared-ux/page/analytics_no_data/BUILD.bazel b/packages/shared-ux/page/analytics_no_data/BUILD.bazel new file mode 100644 index 0000000000000..ad687fe8a220b --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/BUILD.bazel @@ -0,0 +1,139 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "analytics_no_data" +PKG_REQUIRE_NAME = "@kbn/shared-ux-page-analytics-no-data" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//react", + "@npm//rxjs", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-services", + "//packages/kbn-shared-ux-components", + "//packages/kbn-shared-ux-storybook" +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@types/react", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-services:npm_module_types", + "//packages/kbn-shared-ux-storybook:npm_module_types", + "//packages/kbn-shared-ux-components:npm_module_types", + "//packages/kbn-ambient-ui-types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/page/analytics_no_data/README.mdx b/packages/shared-ux/page/analytics_no_data/README.mdx new file mode 100644 index 0000000000000..ab8cf8d1cb063 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/README.mdx @@ -0,0 +1,16 @@ +--- +id: sharedUX/Components/AnalyticsNoDataPage +slug: /shared-ux/components/analytics-no-data-page +title: Analytics "No Data" Page +summary: An entire page that can be displayed when Kibana "has no data", specifically for Analytics. +tags: ['shared-ux', 'component'] +date: 2021-12-28 +--- + +## Description + +This is an Analytics-specific version of `KibanaNoDataPage`, which defaults most of the fields to give a consistent set of terms for Analytics solutions. + +## EUI Promotion Status + +This component is not currently considered for promotion to EUI. diff --git a/packages/shared-ux/page/analytics_no_data/jest.config.js b/packages/shared-ux/page/analytics_no_data/jest.config.js new file mode 100644 index 0000000000000..76067f82881f7 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/jest.config.js @@ -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 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/page/analytics_no_data'], +}; diff --git a/packages/shared-ux/page/analytics_no_data/package.json b/packages/shared-ux/page/analytics_no_data/package.json new file mode 100644 index 0000000000000..e9977444fb94e --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-page-analytics-no-data", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap b/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap new file mode 100644 index 0000000000000..be6fd3c45744e --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnalyticsNoDataPageComponent renders correctly 1`] = ` + + + + } + > + +
+ + + +
+
+
+
+
+
+`; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.tsx new file mode 100644 index 0000000000000..0f18710197991 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.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 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { KibanaNoDataPage } from '@kbn/shared-ux-components'; +import { AnalyticsNoDataPage } from './analytics_no_data_page.component'; + +describe('AnalyticsNoDataPageComponent', () => { + const onDataViewCreated = jest.fn(); + + it('renders correctly', () => { + const component = mountWithIntl( + + ); + expect(component).toMatchSnapshot(); + + expect(component.find(KibanaNoDataPage).length).toBe(1); + + const noDataConfig = component.find(KibanaNoDataPage).props().noDataConfig; + expect(noDataConfig.solution).toEqual('Analytics'); + expect(noDataConfig.pageTitle).toEqual('Welcome to Analytics!'); + expect(noDataConfig.logo).toEqual('logoKibana'); + expect(noDataConfig.docsLink).toEqual('http://www.test.com'); + expect(noDataConfig.action.elasticAgent).not.toBeNull(); + }); +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx new file mode 100644 index 0000000000000..31051328641f4 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { i18n } from '@kbn/i18n'; +import { KibanaNoDataPage } from '@kbn/shared-ux-components'; + +/** + * Props for the pure component. + */ +export interface Props { + kibanaGuideDocLink: string; + onDataViewCreated: (dataView: unknown) => void; +} + +const solution = i18n.translate('sharedUXPackages.noDataConfig.analytics', { + defaultMessage: 'Analytics', +}); + +const pageTitle = i18n.translate('sharedUXPackages.noDataConfig.analyticsPageTitle', { + defaultMessage: 'Welcome to Analytics!', +}); + +const addIntegrationsTitle = i18n.translate('sharedUXPackages.noDataConfig.addIntegrationsTitle', { + defaultMessage: 'Add integrations', +}); + +const addIntegrationsDescription = i18n.translate( + 'sharedUXPackages.noDataConfig.addIntegrationsDescription', + { + defaultMessage: 'Use Elastic Agent to collect data and build out Analytics solutions.', + } +); + +/** + * A pure component of an entire page that can be displayed when Kibana "has no data", specifically for Analytics. + */ +export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: Props) => { + const noDataConfig = { + solution, + pageTitle, + logo: 'logoKibana', + action: { + elasticAgent: { + title: addIntegrationsTitle, + description: addIntegrationsDescription, + 'data-test-subj': 'kbnOverviewAddIntegrations', + }, + }, + docsLink: kibanaGuideDocLink, + }; + + return ; +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx new file mode 100644 index 0000000000000..8471cdf9546d2 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { action } from '@storybook/addon-actions'; +import { servicesFactory, DataServiceFactoryConfig } from '@kbn/shared-ux-storybook'; + +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page'; +import { AnalyticsNoDataPageProvider, Services } from './services'; +import mdx from '../README.mdx'; + +export default { + title: 'Analytics No Data Page', + description: 'An Analytics-specific version of KibanaNoDataPage.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +type Params = Pick; + +export const AnalyticsNoDataPage = (params: Params) => { + // Workaround to leverage the services package. + const { application, data, docLinks, editors, http, permissions, platform } = + servicesFactory(params); + + const services: Services = { + ...application, + ...data, + ...docLinks, + ...editors, + ...http, + ...permissions, + ...platform, + kibanaGuideDocLink: 'Kibana guide', + }; + + return ( + + + + ); +}; + +AnalyticsNoDataPage.argTypes = { + hasESData: { + control: 'boolean', + defaultValue: false, + }, + hasUserDataView: { + control: 'boolean', + defaultValue: false, + }, +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx new file mode 100644 index 0000000000000..e091cac70d32b --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { mockServicesFactory } from '@kbn/shared-ux-services'; + +import { Services, AnalyticsNoDataPageProvider } from './services'; +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.component'; +import { AnalyticsNoDataPage } from './analytics_no_data_page'; + +describe('AnalyticsNoDataPage', () => { + const onDataViewCreated = jest.fn(); + + // Workaround to leverage the services package. + const { application, data, docLinks, editors, http, permissions, platform } = + mockServicesFactory(); + + const services: Services = { + ...application, + ...data, + ...docLinks, + ...editors, + ...http, + ...permissions, + ...platform, + kibanaGuideDocLink: 'Kibana guide', + }; + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('renders correctly', async () => { + const component = mountWithIntl( + + + + ); + + expect(component.find(Component).length).toBe(1); + expect(component.find(Component).props().kibanaGuideDocLink).toBe(services.kibanaGuideDocLink); + expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); + }); +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx new file mode 100644 index 0000000000000..141f607a6257e --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { LegacyServicesProvider, getLegacyServices } from './legacy_services'; +import { useServices } from './services'; +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.component'; + +/** + * Props for the `AnalyticsNoDataPage` component. + */ +export interface AnalyticsNoDataPageProps { + onDataViewCreated: (dataView: unknown) => void; +} + +/** + * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. Uses + * services from a provider to provide props to a pure component. + */ +export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPageProps) => { + const services = useServices(); + const { kibanaGuideDocLink } = services; + + return ( + + + + ); +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/index.ts b/packages/shared-ux/page/analytics_no_data/src/index.ts new file mode 100644 index 0000000000000..7b87084f745ef --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { withSuspense } from '@kbn/shared-ux-utility'; + +export { AnalyticsNoDataPageProvider, AnalyticsNoDataPageKibanaProvider } from './services'; + +/** + * Lazy-loaded connected component. Must be wrapped in `React.Suspense`. + */ +export const LazyAnalyticsNoDataPage = React.lazy(() => + import('./analytics_no_data_page').then(({ AnalyticsNoDataPage }) => ({ + default: AnalyticsNoDataPage, + })) +); + +/** + * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. + * Requires a Provider for relevant services. + */ +export const AnalyticsNoDataPage = withSuspense(LazyAnalyticsNoDataPage); diff --git a/packages/shared-ux/page/analytics_no_data/src/legacy_services.tsx b/packages/shared-ux/page/analytics_no_data/src/legacy_services.tsx new file mode 100644 index 0000000000000..3d690e56e0d23 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/legacy_services.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 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 { SharedUxServicesProvider as LegacyServicesProvider } from '@kbn/shared-ux-services'; +export type { SharedUxServices as LegacyServices } from '@kbn/shared-ux-services'; + +import { SharedUxServices as LegacyServices } from '@kbn/shared-ux-services'; +import { Services } from './services'; + +/** + * This list is temporary, a stop-gap as we migrate to a package-based architecture, where + * services are not collected in a single package. In order to make the transition, this + * interface is intentionally "flat". + * + * Expect this list to dwindle to zero as `@kbn/shared-ux-components` are migrated to their + * own packages, (and `@kbn/shared-ux-services` is removed). + */ +export const getLegacyServices = (services: Services): LegacyServices => ({ + application: { + currentAppId$: services.currentAppId$, + navigateToUrl: services.navigateToUrl, + }, + data: { + hasESData: services.hasESData, + hasDataView: services.hasDataView, + hasUserDataView: services.hasUserDataView, + }, + docLinks: { + dataViewsDocLink: services.dataViewsDocLink, + }, + editors: { + openDataViewEditor: services.openDataViewEditor, + }, + http: { + addBasePath: services.addBasePath, + }, + permissions: { + canAccessFleet: services.canAccessFleet, + canCreateNewDataView: services.canCreateNewDataView, + }, + platform: { + setIsFullscreen: services.setIsFullscreen, + }, +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/services.tsx b/packages/shared-ux/page/analytics_no_data/src/services.tsx new file mode 100644 index 0000000000000..70ba29ed2f648 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/services.tsx @@ -0,0 +1,162 @@ +/* + * Copyright 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, { FC, useContext } from 'react'; +import { Observable } from 'rxjs'; + +/** + * TODO: `DataView` is a class exported by `src/plugins/data_views/public`. Since this service + * is contained in this package-- and packages can only depend on other packages and never on + * plugins-- we have to set this to `unknown`. If and when `DataView` is exported from a + * stateless package, we can remove this. + * + * @see: https://github.com/elastic/kibana/issues/127695 + */ +type DataView = unknown; + +/** + * A subset of the `DataViewEditorOptions` interface relevant to this component. + * + * @see: src/plugins/data_view_editor/public/types.ts + */ +interface DataViewEditorOptions { + /** Handler to be invoked when the Data View Editor completes a save operation. */ + onSave: (dataView: DataView) => void; +} + +/** + * A list of Services that are consumed by this component. + * + * This list is temporary, a stopgap as we migrate to a package-based architecture, where + * services are not collected in a single package. In order to make the transition, this + * interface is intentionally "flat". + * + * Expect this list to dwindle to zero as `@kbn/shared-ux-components` are migrated to their + * own packages, (and `@kbn/shared-ux-services` is removed). + */ +export interface Services { + addBasePath: (url: string) => string; + canAccessFleet: boolean; + canCreateNewDataView: boolean; + currentAppId$: Observable; + dataViewsDocLink: string; + hasDataView: () => Promise; + hasESData: () => Promise; + hasUserDataView: () => Promise; + kibanaGuideDocLink: string; + navigateToUrl: (url: string) => Promise; + openDataViewEditor: (options: DataViewEditorOptions) => () => void; + setIsFullscreen: (isFullscreen: boolean) => void; +} + +const AnalyticsNoDataPageContext = React.createContext(null); + +/** + * A Context Provider that provides services to the component. + */ +export const AnalyticsNoDataPageProvider: FC = ({ children, ...services }) => { + return ( + + {children} + + ); +}; + +/** + * An interface containing a collection of Kibana plugins and services required to + * render this component and its dependencies. + */ +export interface AnalyticsNoDataPageKibanaDependencies { + coreStart: { + application: { + capabilities: { + navLinks: { + integrations: boolean; + }; + }; + currentAppId$: Observable; + navigateToUrl: (url: string) => Promise; + }; + chrome: { + setIsVisible: (isVisible: boolean) => void; + }; + docLinks: { + links: { + indexPatterns: { + introduction: string; + }; + kibana: { + guide: string; + }; + }; + }; + http: { + basePath: { + prepend: (url: string) => string; + }; + }; + }; + dataViews: { + hasData: { + hasDataView: () => Promise; + hasESData: () => Promise; + hasUserDataView: () => Promise; + }; + }; + dataViewEditor: { + openEditor: (options: DataViewEditorOptions) => () => void; + userPermissions: { + editDataView: () => boolean; + }; + }; +} + +/** + * Kibana-specific Provider that maps dependencies to services. + */ +export const AnalyticsNoDataPageKibanaProvider: FC = ({ + children, + ...dependencies +}) => { + const { coreStart, dataViewEditor, dataViews } = dependencies; + const value: Services = { + addBasePath: coreStart.http.basePath.prepend, + canAccessFleet: coreStart.application.capabilities.navLinks.integrations, + canCreateNewDataView: dataViewEditor.userPermissions.editDataView(), + currentAppId$: coreStart.application.currentAppId$, + dataViewsDocLink: coreStart.docLinks.links.indexPatterns?.introduction, + hasDataView: dataViews.hasData.hasDataView, + hasESData: dataViews.hasData.hasESData, + hasUserDataView: dataViews.hasData.hasUserDataView, + kibanaGuideDocLink: coreStart.docLinks.links.kibana.guide, + navigateToUrl: coreStart.application.navigateToUrl, + openDataViewEditor: dataViewEditor.openEditor, + setIsFullscreen: (isVisible: boolean) => coreStart.chrome.setIsVisible(isVisible), + }; + + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(AnalyticsNoDataPageContext); + + if (!context) { + throw new Error( + 'AnalyticsNoDataPageContext is missing. Ensure your component or React root is wrapped with AnalyticsNoDataPageContext.' + ); + } + + return context; +} diff --git a/packages/shared-ux/page/analytics_no_data/tsconfig.json b/packages/shared-ux/page/analytics_no_data/tsconfig.json new file mode 100644 index 0000000000000..573ad07325100 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/src/core/public/analytics/analytics_service.ts b/src/core/public/analytics/analytics_service.ts index 723122ffbaef2..f1c00b293808b 100644 --- a/src/core/public/analytics/analytics_service.ts +++ b/src/core/public/analytics/analytics_service.ts @@ -9,6 +9,7 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; import { of } from 'rxjs'; +import { trackClicks } from './track_clicks'; import { InjectedMetadataSetup } from '../injected_metadata'; import { CoreContext } from '../core_system'; import { getSessionId } from './get_session_id'; @@ -53,6 +54,7 @@ export class AnalyticsService { // and can benefit other consumers of the client. this.registerSessionIdContext(); this.registerBrowserInfoAnalyticsContext(); + trackClicks(this.analyticsClient, core.env.mode.dev); } public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup { diff --git a/src/core/public/analytics/track_clicks.test.ts b/src/core/public/analytics/track_clicks.test.ts new file mode 100644 index 0000000000000..db1b8fc215cf8 --- /dev/null +++ b/src/core/public/analytics/track_clicks.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { firstValueFrom, ReplaySubject } from 'rxjs'; +import { analyticsClientMock } from './analytics_service.test.mocks'; +import { trackClicks } from './track_clicks'; +import { take } from 'rxjs/operators'; + +describe('trackClicks', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('registers the analytics event type and a listener to the "click" events', () => { + trackClicks(analyticsClientMock, true); + + expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.registerEventType).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'click', + }) + ); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined); + }); + + test('reports an analytics event when a click event occurs', async () => { + // Gather an actual "click" event + const event$ = new ReplaySubject(1); + const parent = document.createElement('div'); + parent.setAttribute('data-test-subj', 'test-click-target-parent'); + const element = document.createElement('button'); + parent.appendChild(element); + element.setAttribute('data-test-subj', 'test-click-target'); + element.innerText = 'test'; // Only to validate that it is not included in the event. + element.value = 'test'; // Only to validate that it is not included in the event. + element.addEventListener('click', (e) => event$.next(e)); + element.click(); + // Using an observable because the event might not be immediately available + const event = await firstValueFrom(event$.pipe(take(1))); + event$.complete(); // No longer needed + + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + (addEventListenerSpy.mock.calls[0][1] as EventListener)(event); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('click', { + target: [ + 'DIV', + 'data-test-subj=test-click-target-parent', + 'BUTTON', + 'data-test-subj=test-click-target', + ], + }); + }); + + test('handles any processing errors logging them in dev mode', async () => { + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to report the click event", + Object { + "error": [TypeError: Cannot read properties of null (reading 'parentElement')], + "event": MouseEvent { + "isTrusted": false, + }, + }, + ] + `); + }); + + test('swallows any processing errors when not in dev mode', async () => { + trackClicks(analyticsClientMock, false); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/core/public/analytics/track_clicks.ts b/src/core/public/analytics/track_clicks.ts new file mode 100644 index 0000000000000..f2ba7c25de768 --- /dev/null +++ b/src/core/public/analytics/track_clicks.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { fromEvent } from 'rxjs'; +import type { AnalyticsClient } from '@kbn/analytics-client'; + +/** HTML attributes that should be skipped from reporting because they might contain user data */ +const POTENTIAL_PII_HTML_ATTRIBUTES = ['value']; + +/** + * Registers the event type "click" in the analytics client. + * Then it listens to all the "click" events in the UI and reports them with the `target` property being a + * full list of the element's and its parents' attributes. This allows + * @param analytics + */ +export function trackClicks(analytics: AnalyticsClient, isDevMode: boolean) { + analytics.registerEventType<{ target: string[] }>({ + eventType: 'click', + schema: { + target: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'The attributes of the clicked element and all its parents in the form `{attr.name}={attr.value}`. It allows finding the clicked elements by looking up its attributes like "data-test-subj=my-button".', + }, + }, + }, + }, + }); + + // window or document? + // I tested it on multiple browsers and it seems to work the same. + // My assumption is that window captures other documents like iframes as well? + return fromEvent(window, 'click').subscribe((event) => { + try { + const target = event.target as HTMLElement; + analytics.reportEvent('click', { target: getTargetDefinition(target) }); + } catch (error) { + if (isDevMode) { + // Defensively log the error in dev mode to catch any potential bugs. + // eslint-disable-next-line no-console + console.error(`Failed to report the click event`, { event, error }); + } + } + }); +} + +/** + * Returns a list of strings consisting on the tag name and all the attributes of the element. + * Additionally, it recursively walks up the DOM tree to find all the parents' definitions and prepends them to the list. + * + * @example + * From + * ```html + *
+ *
+ *
+ * ``` + * it returns ['DIV', 'data-test-subj=my-parent', 'DIV', 'data-test-subj=my-button'] + * @param target The child node to start from. + */ +function getTargetDefinition(target: HTMLElement): string[] { + return [ + ...(target.parentElement ? getTargetDefinition(target.parentElement) : []), + target.tagName, + ...[...target.attributes] + .filter((attr) => !POTENTIAL_PII_HTML_ATTRIBUTES.includes(attr.name)) + .map((attr) => `${attr.name}=${attr.value}`), + ]; +} diff --git a/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts b/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts index 7c75470b890aa..5d831a5bb8f78 100644 --- a/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import * as Either from 'fp-ts/lib/Either'; import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; import { errors as EsErrors } from '@elastic/elasticsearch'; jest.mock('./catch_retryable_es_client_errors'); @@ -16,16 +17,16 @@ describe('initAction', () => { beforeEach(() => { jest.clearAllMocks(); }); - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); const task = initAction({ client, indices: ['my_index'] }); try { await task(); @@ -34,4 +35,88 @@ describe('initAction', () => { } expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); }); + it('resolves right when persistent and transient cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when persistent and transient cluster settings are undefined', async () => { + const clusterSettingsResponse = { + transient: {}, + persistent: {}, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when persistent cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: {}, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when transient cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: {}, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when valid transient settings, incompatible persistent settings', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: { 'cluster.routing.allocation.enable': 'primaries' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves left when valid persistent settings, incompatible transient settings', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'primaries' }, + persistent: { 'cluster.routing.allocation.enable': 'alls' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isLeft(result)).toEqual(true); + }); + it('resolves left when transient cluster settings are incompatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'none' }, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isLeft(result)).toEqual(true); + }); }); diff --git a/src/core/server/saved_objects/migrations/actions/initialize_action.ts b/src/core/server/saved_objects/migrations/actions/initialize_action.ts index 281e3a0a4f3e0..e7f011cb4c5f2 100644 --- a/src/core/server/saved_objects/migrations/actions/initialize_action.ts +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.ts @@ -44,16 +44,15 @@ export const checkClusterRoutingAllocationEnabledTask = flat_settings: true, }) .then((settings) => { - const clusterRoutingAllocations: string[] = + // transient settings take preference over persistent settings + const clusterRoutingAllocation = settings?.transient?.[routingAllocationEnable] ?? - settings?.persistent?.[routingAllocationEnable] ?? - []; + settings?.persistent?.[routingAllocationEnable]; - const clusterRoutingAllocationEnabled = - [...clusterRoutingAllocations].length === 0 || - [...clusterRoutingAllocations].every((s: string) => s === 'all'); // if set, only allow 'all' + const clusterRoutingAllocationEnabledIsAll = + clusterRoutingAllocation === undefined || clusterRoutingAllocation === 'all'; - if (!clusterRoutingAllocationEnabled) { + if (!clusterRoutingAllocationEnabledIsAll) { return Either.left({ type: 'unsupported_cluster_routing_allocation' as const, message: diff --git a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts index 9846e5f48dc21..cddd2f323f1fc 100644 --- a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts @@ -116,7 +116,7 @@ describe('migration actions', () => { await client.cluster.putSettings({ body: { persistent: { - // Remove persistent test settings + // Reset persistent test settings cluster: { routing: { allocation: { enable: null } } }, }, }, @@ -126,11 +126,11 @@ describe('migration actions', () => { expect.assertions(1); const task = initAction({ client, indices: ['no_such_index'] }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object {}, - } - `); + Object { + "_tag": "Right", + "right": Object {}, + } + `); }); it('resolves right record with found indices', async () => { expect.assertions(1); @@ -149,7 +149,7 @@ describe('migration actions', () => { }) ); }); - it('resolves left with cluster routing allocation disabled', async () => { + it('resolves left when cluster.routing.allocation.enabled is incompatible', async () => { expect.assertions(3); await client.cluster.putSettings({ body: { @@ -164,14 +164,14 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", + "type": "unsupported_cluster_routing_allocation", + }, + } + `); await client.cluster.putSettings({ body: { persistent: { @@ -185,14 +185,14 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task2()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", + "type": "unsupported_cluster_routing_allocation", + }, + } + `); await client.cluster.putSettings({ body: { persistent: { @@ -206,14 +206,30 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task3()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", + "type": "unsupported_cluster_routing_allocation", + }, + } + `); + }); + it('resolves right when cluster.routing.allocation.enabled=all', async () => { + expect.assertions(1); + await client.cluster.putSettings({ + body: { + persistent: { + cluster: { routing: { allocation: { enable: 'all' } } }, + }, + }, + }); + const task = initAction({ + client, + indices: ['existing_index_with_docs'], + }); + const result = await task(); + expect(Either.isRight(result)).toBe(true); }); }); @@ -271,14 +287,14 @@ describe('migration actions', () => { expect.assertions(1); const task = setWriteBlock({ client, index: 'no_such_index' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); }); @@ -300,21 +316,21 @@ describe('migration actions', () => { expect.assertions(1); const task = removeWriteBlock({ client, index: 'existing_index_with_write_block_2' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "remove_write_block_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "remove_write_block_succeeded", + } + `); }); it('resolves right if successful when an index does not have a write block', async () => { expect.assertions(1); const task = removeWriteBlock({ client, index: 'existing_index_without_write_block_2' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "remove_write_block_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "remove_write_block_succeeded", + } + `); }); it('rejects if there is a non-retryable error', async () => { expect.assertions(1); @@ -398,14 +414,14 @@ describe('migration actions', () => { timeout: '1s', }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [red_index] index to become 'yellow'", - "type": "index_not_yellow_timeout", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [red_index] index to become 'yellow'", + "type": "index_not_yellow_timeout", + }, + } + `); }); }); @@ -425,14 +441,14 @@ describe('migration actions', () => { }); expect.assertions(1); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); @@ -491,14 +507,14 @@ describe('migration actions', () => { expect.assertions(1); const task = cloneIndex({ client, source: 'no_such_index', target: 'clone_target_3' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); it('resolves left with a index_not_yellow_timeout if clone target already exists but takes longer than the specified timeout before turning yellow', async () => { // Create a red index @@ -527,14 +543,14 @@ describe('migration actions', () => { })(); await expect(cloneIndexPromise).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [clone_red_index] index to become 'yellow'", - "type": "index_not_yellow_timeout", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [clone_red_index] index to become 'yellow'", + "type": "index_not_yellow_timeout", + }, + } + `); // Now that we know timeouts work, make the index yellow again and call cloneIndex a second time to verify that it completes @@ -555,14 +571,14 @@ describe('migration actions', () => { })(); await expect(cloneIndexPromise2).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); }); @@ -580,11 +596,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { @@ -620,11 +636,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { @@ -653,11 +669,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { batchSize: 1000, diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 88240429856d1..49967feb214d6 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -65,16 +65,16 @@ export const CreateDockerUbuntu: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubuntu', context: false, image: true, - ubuntu: true, dockerBuildDate, }); await runDockerGenerator(config, log, build, { architecture: 'aarch64', + baseImage: 'ubuntu', context: false, image: true, - ubuntu: true, dockerBuildDate, }); }, @@ -86,8 +86,8 @@ export const CreateDockerUBI: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubi', context: false, - ubi: true, image: true, }); }, @@ -99,16 +99,16 @@ export const CreateDockerCloud: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubuntu', context: false, cloud: true, - ubuntu: true, image: true, }); await runDockerGenerator(config, log, build, { architecture: 'aarch64', + baseImage: 'ubuntu', context: false, cloud: true, - ubuntu: true, image: true, }); }, @@ -119,23 +119,25 @@ export const CreateDockerContexts: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubuntu: true, + baseImage: 'ubuntu', context: true, image: false, dockerBuildDate, }); await runDockerGenerator(config, log, build, { - ubi: true, + baseImage: 'ubi', context: true, image: false, }); await runDockerGenerator(config, log, build, { ironbank: true, + baseImage: 'none', context: true, image: false, }); await runDockerGenerator(config, log, build, { + baseImage: 'ubuntu', cloud: true, context: true, image: false, diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 264c6e52db0eb..d8b604f00b46e 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -29,22 +29,21 @@ export async function runDockerGenerator( build: Build, flags: { architecture?: string; + baseImage: 'none' | 'ubi' | 'ubuntu'; context: boolean; image: boolean; - ubi?: boolean; - ubuntu?: boolean; ironbank?: boolean; cloud?: boolean; dockerBuildDate?: string; } ) { - let baseOSImage = ''; - if (flags.ubuntu) baseOSImage = 'ubuntu:20.04'; - if (flags.ubi) baseOSImage = 'docker.elastic.co/ubi8/ubi-minimal:latest'; + let baseImageName = ''; + if (flags.baseImage === 'ubuntu') baseImageName = 'ubuntu:20.04'; + if (flags.baseImage === 'ubi') baseImageName = 'docker.elastic.co/ubi8/ubi-minimal:latest'; const ubiVersionTag = 'ubi8'; let imageFlavor = ''; - if (flags.ubi) imageFlavor += `-${ubiVersionTag}`; + if (flags.baseImage === 'ubi') imageFlavor += `-${ubiVersionTag}`; if (flags.ironbank) imageFlavor += '-ironbank'; if (flags.cloud) imageFlavor += '-cloud'; @@ -61,7 +60,6 @@ export async function runDockerGenerator( const artifactsDir = config.resolveFromTarget('.'); const beatsDir = config.resolveFromRepo('.beats'); const dockerBuildDate = flags.dockerBuildDate || new Date().toISOString(); - // That would produce oss, default and default-ubi7 const dockerBuildDir = config.resolveFromRepo('build', 'kibana-docker', `default${imageFlavor}`); const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( @@ -93,10 +91,9 @@ export async function runDockerGenerator( dockerPush, dockerTagQualifier, dockerCrossCompile, - baseOSImage, + baseImageName, dockerBuildDate, - ubi: flags.ubi, - ubuntu: flags.ubuntu, + baseImage: flags.baseImage, cloud: flags.cloud, metricbeatTarball, filebeatTarball, diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 35977d47aaaa7..32a551820a05b 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -20,12 +20,11 @@ export interface TemplateContext { imageTag: string; dockerBuildDir: string; dockerTargetFilename: string; - baseOSImage: string; dockerBuildDate: string; usePublicArtifact?: boolean; publicArtifactSubdomain: string; - ubi?: boolean; - ubuntu?: boolean; + baseImage: 'none' | 'ubi' | 'ubuntu'; + baseImageName: string; cloud?: boolean; metricbeatTarball?: string; filebeatTarball?: string; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index 95f6a56ef68cb..d171c48662cf6 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -9,7 +9,7 @@ # Build stage 0 `builder`: # Extract Kibana artifact ################################################################################ -FROM {{{baseOSImage}}} AS builder +FROM {{{baseImageName}}} AS builder {{#ubi}} RUN {{packageManager}} install -y findutils tar gzip @@ -54,7 +54,7 @@ RUN mkdir -p /opt/filebeat /opt/metricbeat && \ # Copy kibana from stage 0 # Add entrypoint ################################################################################ -FROM {{{baseOSImage}}} +FROM {{{baseImageName}}} EXPOSE 5601 {{#ubi}} diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 316428d46a957..472e64e849b58 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -18,7 +18,7 @@ function generator({ dockerCrossCompile, version, dockerTargetFilename, - baseOSImage, + baseImageName, architecture, }: TemplateContext) { const dockerTargetName = `${imageTag}${imageFlavor}:${version}${ @@ -61,7 +61,7 @@ function generator({ done } - retry_docker_pull ${baseOSImage} + retry_docker_pull ${baseImageName} echo "Building: kibana${imageFlavor}-docker"; \\ ${dockerBuild} diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index 94068f2b64b12..63b04ed6f70b0 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -16,7 +16,9 @@ function generator(options: TemplateContext) { const dir = options.ironbank ? 'ironbank' : 'base'; const template = readFileSync(resolve(__dirname, dir, './Dockerfile')); return Mustache.render(template.toString(), { - packageManager: options.ubi ? 'microdnf' : 'apt-get', + packageManager: options.baseImage === 'ubi' ? 'microdnf' : 'apt-get', + ubi: options.baseImage === 'ubi', + ubuntu: options.baseImage === 'ubuntu', ...options, }); } diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 8aa2d6f1cfe55..ced601d0f3981 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -146,10 +146,10 @@ export const TEMPORARILY_IGNORED_PATHS = [ 'x-pack/plugins/monitoring/public/icons/health-green.svg', 'x-pack/plugins/monitoring/public/icons/health-red.svg', 'x-pack/plugins/monitoring/public/icons/health-yellow.svg', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Medium.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Regular.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Italic.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Medium.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Regular.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/img/logo-grey.png', + 'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Medium.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Regular.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Italic.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Medium.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Regular.ttf', + 'x-pack/plugins/screenshotting/server/assets/img/logo-grey.png', ]; diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx index 555df3c2c5c11..0e67d787be144 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx @@ -388,7 +388,7 @@ describe('Field', () => { const updated = wrapper.update(); findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click'); expect(handleChange).toBeCalledWith(setting.name, { - value: getEditableValue(setting.type, setting.defVal), + value: getEditableValue(setting.type, setting.defVal, setting.defVal), changeImage: true, }); }); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index fd4674a7caf6e..56673cda1a953 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -100,7 +100,7 @@ export class Field extends PureComponent { if (type === 'image') { this.cancelChangeImage(); return this.handleChange({ - value: getEditableValue(type, defVal), + value: getEditableValue(type, defVal, defVal), changeImage: true, }); } diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts index ac8cb5c8b86a3..705516a9f1e96 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts @@ -111,6 +111,7 @@ describe('interpreter/functions#gauge', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index 48c7261e43016..70ecd25839d19 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -194,6 +194,9 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ } if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( data, [ diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts index 13beee6b0f701..d34442ca3f518 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts @@ -69,6 +69,7 @@ describe('interpreter/functions#heatmap', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts index c440176962faf..954c5acee7152 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -161,6 +161,9 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ validateAccessor(args.splitColumnAccessor, data.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = []; if (args.valueAccessor) { prepareHeatmapLogTable( diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts index 1135708db8c22..79a356ddad934 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; @@ -52,10 +53,19 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionHeatmap.function.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, fn(input, args) { diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts index d3e7444ad08f2..19f63f9df9890 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -15,6 +15,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EXPRESSION_HEATMAP_NAME, EXPRESSION_HEATMAP_LEGEND_NAME, @@ -43,7 +44,7 @@ export interface HeatmapLegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export type HeatmapLegendConfigResult = HeatmapLegendConfig & { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index 4f3e77b8f1d6e..19a57272116c8 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -17,6 +17,7 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { act } from 'react-dom/test-utils'; import { HeatmapRenderProps, HeatmapArguments } from '../../common'; import HeatmapComponent from './heatmap_component'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -47,6 +48,7 @@ const args: HeatmapArguments = { isVisible: true, position: 'top', type: 'heatmap_legend', + legendSize: LegendSize.SMALL, }, gridConfig: { isCellLabelVisible: true, @@ -119,6 +121,33 @@ describe('HeatmapComponent', function () { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('sets correct legend sizes', () => { + const component = shallowWithIntl(); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: LegendSize.AUTO, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: undefined, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('renders the legend toggle component if uiState is set', async () => { const component = mountWithIntl(); await actWithTimeout(async () => { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index a9b70d1bc2edd..36270ef896e46 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -29,6 +29,10 @@ import { getAccessorByDimension, getFormatByAccessor, } from '@kbn/visualizations-plugin/common/utils'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; import { applyPaletteParams, findMinMaxByColumnId, getSortPredicate } from './helpers'; import { @@ -485,7 +489,7 @@ export const HeatmapComponent: FC = memo( onElementClick={interactive ? (onElementClick as ElementClickListener) : undefined} showLegend={showLegend ?? args.legend.isVisible} legendPosition={args.legend.position} - legendSize={args.legend.legendSize} + legendSize={LegendSizeToPixels[args.legend.legendSize ?? DEFAULT_LEGEND_SIZE]} legendColorPicker={uiState ? LegendColorPickerWrapper : undefined} debugState={window._echDebugStateFlag ?? false} tooltip={tooltip} diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts index 65f738e8e227d..6524c15c44af1 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts @@ -68,6 +68,7 @@ describe('interpreter/functions#metric', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 2310ffb8c5926..add31e7b12014 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -146,6 +146,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ validateAccessor(args.bucket, input.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = [ [ args.metric, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 695b7ad4754fa..8c370480a7be9 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -54,4 +54,6 @@ export interface MetricOptions { color?: string; bgColor?: string; lightText: boolean; + colIndex: number; + rowIndex: number; } diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap index 684f42d527c19..b18c521bea653 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap @@ -19,6 +19,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "1st percentile of bytes", "lightText": false, @@ -26,6 +27,7 @@ Array [ "value": 182, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -53,6 +55,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 1, "color": undefined, "label": "99th percentile of bytes", "lightText": false, @@ -60,6 +63,7 @@ Array [ "value": 445842.4634666484, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -91,6 +95,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "Count", "lightText": false, @@ -98,6 +103,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` "value": 4301021, } } + onFilter={[Function]} style={ Object { "bgColor": false, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx index 50853ea6c6569..6fe19c0e72515 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx @@ -63,6 +63,7 @@ class MetricVisComponent extends Component { return dimensions.metrics.reduce( (acc: MetricOptions[], metric: string | ExpressionValueVisDimension) => { const column = getColumnByAccessor(metric, table?.columns); + const colIndex = table?.columns.indexOf(column!); const formatter = getFormatService().deserialize( getFormatByAccessor(metric, table.columns) ); @@ -89,6 +90,7 @@ class MetricVisComponent extends Component { bgColor: shouldBrush && (style.bgColor ?? false) ? color : undefined, lightText: shouldBrush && (style.bgColor ?? false) && needsLightText(color), rowIndex, + colIndex, }; }); @@ -98,20 +100,21 @@ class MetricVisComponent extends Component { ); } - private filterBucket = (row: number) => { + private filterColumn = (row: number, metricColIndex: number) => { const { dimensions } = this.props.visParams; - if (!dimensions.bucket) { - return; - } const table = this.props.visData; + let column = dimensions.bucket ? getAccessor(dimensions.bucket) : metricColIndex; + if (typeof column === 'object' && 'id' in column) { + column = table.columns.indexOf(column); + } this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { table, - column: getAccessor(dimensions.bucket), + column, row, }, ], @@ -144,9 +147,7 @@ class MetricVisComponent extends Component { key={index} metric={metric} style={this.props.visParams.metric.style} - onFilter={ - this.props.visParams.dimensions.bucket ? () => this.filterBucket(index) : undefined - } + onFilter={() => this.filterColumn(metric.rowIndex, metric.colIndex)} autoScale={this.props.visParams.metric.autoScale} colorFullBackground={this.props.visParams.metric.colorFullBackground} labelConfig={this.props.visParams.metric.labels} diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx index f86f70341891c..fee24d8aa5e7f 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx @@ -13,7 +13,13 @@ import { MetricVisValue } from './metric_value'; import { MetricOptions, MetricStyle, VisParams } from '../../common/types'; import { LabelPosition } from '../../common/constants'; -const baseMetric: MetricOptions = { label: 'Foo', value: 'foo', lightText: false }; +const baseMetric: MetricOptions = { + label: 'Foo', + value: 'foo', + lightText: false, + rowIndex: 0, + colIndex: 0, +}; const font: MetricStyle = { spec: { fontSize: '12px' }, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx index e948b95af52fe..40de364cfa5dc 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx @@ -8,6 +8,7 @@ import React, { CSSProperties } from 'react'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import type { MetricOptions, MetricStyle, MetricVisParam } from '../../common/types'; interface MetricVisValueProps { @@ -72,7 +73,13 @@ export const MetricVisValue = ({ if (onFilter) { return ( - ); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap index 81ada60a772cd..2a06459822a0e 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap index 28d5f35c89cbf..0f64f4c0a4779 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, @@ -246,7 +246,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap index e1d9f98f57209..9f6210f42b48a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap index 33525b33f6f96..9cdc69904460a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap @@ -86,7 +86,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index 250d0f1033ffe..d7839d1f7d1e9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -47,7 +47,7 @@ export const strings = { }), getLegendSizeArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.legendSizeHelpText', { - defaultMessage: 'Specifies the legend size in pixels', + defaultMessage: 'Specifies the legend size', }), getNestedLegendArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.nestedLegendHelpText', { diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts index 0ce174b38677f..54b478e7deed9 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#mosaicVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index 74a85dd01e6e4..ae3f17ff8df3a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -134,6 +144,9 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index c542a25c30875..2ac50372e178d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -14,7 +14,7 @@ import { ValueFormats, LegendDisplay, } from '../types/expression_renderers'; -import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { ExpressionValueVisDimension, LegendSize } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { pieVisFunction } from './pie_vis_function'; import { PARTITION_LABELS_VALUE } from '../constants'; @@ -31,6 +31,7 @@ describe('interpreter/functions#pieVis', () => { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.SMALL, isDonut: true, emptySizeRatio: EmptySizeRatios.SMALL, nestedLegend: true, @@ -128,6 +129,7 @@ describe('interpreter/functions#pieVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index 9a30008cc6bb3..5b69fbc6194fd 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -154,6 +164,9 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts index 5d2cd5b8a0c38..e10dbf09dd179 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#treemapVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index 062cf7e78b4ea..427179ca5a25a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -134,6 +144,9 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts index 01cbe844728b3..af36e4ea04a10 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts @@ -106,6 +106,7 @@ describe('interpreter/functions#waffleVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 2f947a3d5fea6..0867e6cb9bd76 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { @@ -63,8 +64,17 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, truncateLegend: { types: ['boolean'], @@ -129,6 +139,9 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index 05613af4f2f33..89a242fe26de1 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -11,6 +11,7 @@ import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { ChartTypes, ExpressionValuePartitionLabels } from './expression_functions'; export enum EmptySizeRatios { @@ -52,7 +53,7 @@ interface VisCommonParams { legendPosition: Position; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; ariaLabel?: string; } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index 3c48d3cb36771..0fcee477c99de 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -247,6 +247,7 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -674,6 +675,7 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1054,6 +1056,7 @@ exports[`PartitionVisComponent should render correct structure for pie 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1465,6 +1468,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1866,6 +1870,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 648df546b2992..70c120e4fd759 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -25,6 +25,7 @@ import { createMockWaffleParams, } from '../mocks'; import { ChartTypes } from '../../common/types'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -177,6 +178,35 @@ describe('PartitionVisComponent', function () { expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); }); + it('sets correct legend sizes', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + visParams: { + ...visParams, + legendSize: LegendSize.AUTO, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + visParams: { + ...visParams, + legendSize: undefined, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('defaults on displaying the tooltip', () => { const component = shallow(); expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index ef6d0d1c4525c..d25126869e087 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -22,7 +22,11 @@ import { import { useEuiTheme } from '@elastic/eui'; import type { PaletteRegistry } from '@kbn/coloring'; import { LegendToggle, ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; +import { PersistedState } from '@kbn/visualizations-plugin/public'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { Datatable, @@ -387,7 +391,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { showLegend ?? shouldShowLegend(visType, visParams.legendDisplay, bucketColumns) } legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} legendMaxDepth={visParams.nestedLegend ? undefined : 1} legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined} flatLegend={flatLegend} diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index c9ddd7c30557b..ccc365096495b 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -93,6 +93,7 @@ describe('interpreter/functions#tagcloud', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 49f376a8a4aa3..96857c2ec7426 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -165,7 +165,7 @@ export const TagCloudChart = ({ } fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 931ece6ef8a78..68ac2963c9646 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -10,7 +10,6 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; -export const MULTITABLE = 'lens_multitable'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; export const LEGEND_CONFIG = 'legendConfig'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index 49446310a894b..5a1dad533c084 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -6,13 +6,17 @@ * Side Public License, v 1. */ +import { ArgumentType } from '@kbn/expressions-plugin/common'; import { SeriesTypes, XScaleTypes, YScaleTypes, Y_CONFIG } from '../constants'; import { strings } from '../i18n'; -import { DataLayerFn, ExtendedDataLayerFn } from '../types'; +import { DataLayerArgs, ExtendedDataLayerArgs } from '../types'; -type CommonDataLayerFn = DataLayerFn | ExtendedDataLayerFn; +type CommonDataLayerArgs = ExtendedDataLayerArgs | DataLayerArgs; +type CommonDataLayerFnArgs = { + [key in keyof CommonDataLayerArgs]: ArgumentType; +}; -export const commonDataLayerArgs: CommonDataLayerFn['args'] = { +export const commonDataLayerArgs: CommonDataLayerFnArgs = { hide: { types: ['boolean'], default: false, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts deleted file mode 100644 index 518690d47bfcb..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts +++ /dev/null @@ -1,38 +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 { DataLayerArgs } from '../types'; -import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; -import { mockPaletteOutput, sampleArgs } from '../__mocks__'; -import { LayerTypes } from '../constants'; -import { dataLayerFunction } from './data_layer'; - -describe('dataLayerConfig', () => { - test('produces the correct arguments', () => { - const { data } = sampleArgs(); - const args: DataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - yScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - }; - - const result = dataLayerFunction.fn(data, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'dataLayer', - layerType: LayerTypes.DATA, - ...args, - table: data, - }); - }); -}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts deleted file mode 100644 index f36a0ea4c101f..0000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts +++ /dev/null @@ -1,30 +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 { DataLayerFn } from '../types'; -import { DATA_LAYER, LayerTypes } from '../constants'; -import { strings } from '../i18n'; -import { commonDataLayerArgs } from './common_data_layer_args'; - -export const dataLayerFunction: DataLayerFn = { - name: DATA_LAYER, - aliases: [], - type: DATA_LAYER, - help: strings.getDataLayerFnHelp(), - inputTypes: ['datatable'], - args: { ...commonDataLayerArgs }, - fn(table, args) { - return { - type: DATA_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.DATA, - table, - }; - }, -}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts index ab1d570a07351..30a76217b5c0e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts @@ -13,7 +13,6 @@ export * from './annotation_layer'; export * from './extended_annotation_layer'; export * from './y_axis_config'; export * from './extended_y_axis_config'; -export * from './data_layer'; export * from './extended_data_layer'; export * from './grid_lines_config'; export * from './axis_extent_config'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts index 2b383f1899d44..ddb46d5e55f13 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts @@ -8,6 +8,7 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LEGEND_CONFIG } from '../constants'; import { LegendConfigFn } from '../types'; @@ -85,10 +86,19 @@ export const legendConfigFunction: LegendConfigFn = { }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionXY.legendConfig.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, async fn(input, args, handlers) { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 688efbe122f3e..871135dd45bcb 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -15,16 +15,22 @@ describe('xyVis', () => { test('it renders with the specified data and args', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( data, - { ...rest, dataLayers: [sampleLayer], referenceLineLayers: [], annotationLayers: [] }, + { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, createMockExecutionContext() ); expect(result).toEqual({ type: 'render', as: XY_VIS, - value: { args: { ...rest, layers: [sampleLayer] } }, + value: { + args: { + ...rest, + layers: [{ layerType, table: data, layerId: 'dataLayers-0', type, ...restLayerArgs }], + }, + }, }); }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index 2e97cb00b3e55..e8a5858d3ed26 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -7,9 +7,10 @@ */ import { XyVisFn } from '../types'; -import { XY_VIS, DATA_LAYER, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; +import { XY_VIS, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonXYArgs } from './common_xy_args'; +import { commonDataLayerArgs } from './common_data_layer_args'; export const xyVisFunction: XyVisFn = { name: XY_VIS, @@ -18,11 +19,7 @@ export const xyVisFunction: XyVisFn = { help: strings.getXYHelp(), args: { ...commonXYArgs, - dataLayers: { - types: [DATA_LAYER], - help: strings.getDataLayerHelp(), - multi: true, - }, + ...commonDataLayerArgs, referenceLineLayers: { types: [REFERENCE_LINE_LAYER], help: strings.getReferenceLineLayerHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 1bd75e1296c6c..5e2f7432ddf93 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -7,9 +7,9 @@ */ import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, XY_VIS_RENDERER } from '../constants'; +import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; import { appendLayerIds } from '../helpers'; -import { XYLayerConfig, XyVisFn } from '../types'; +import { DataLayerConfigResult, XYLayerConfig, XyVisFn } from '../types'; import { getLayerDimensions } from '../utils'; import { hasAreaLayer, @@ -21,7 +21,40 @@ import { } from './validate'; export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { - const { dataLayers = [], referenceLineLayers = [], annotationLayers = [], ...restArgs } = args; + const { + referenceLineLayers = [], + annotationLayers = [], + seriesType, + accessors = [], + xAccessor, + hide, + splitAccessor, + columnToLabel, + yScaleType, + xScaleType, + isHistogram, + yConfig, + palette, + ...restArgs + } = args; + const dataLayers: DataLayerConfigResult[] = [ + { + type: DATA_LAYER, + seriesType, + accessors, + xAccessor, + hide, + splitAccessor, + columnToLabel, + yScaleType, + xScaleType, + isHistogram, + palette, + yConfig, + layerType: LayerTypes.DATA, + table: data, + }, + ]; const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), @@ -29,6 +62,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { ]; if (handlers.inspectorAdapters.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const layerDimensions = layers.reduce((dimensions, layer) => { if (layer.layerType === LayerTypes.ANNOTATIONS) { return dimensions; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 4bee4a3e7f2b6..7211a7a7db1b7 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -29,7 +29,6 @@ export type { LegendConfig, IconPosition, DataLayerArgs, - LensMultiTable, ValueLabelMode, AxisExtentMode, DataLayerConfig, diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index b3c7bca93ca29..3cd84fa14682c 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -10,6 +10,7 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/chart import { $Values } from '@kbn/utility-types'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EventAnnotationOutput } from '@kbn/event-annotation-plugin/common'; import { AxisExtentModes, @@ -17,7 +18,6 @@ import { FittingFunctions, IconPositions, LayerTypes, - MULTITABLE, LineStyles, SeriesTypes, ValueLabelModes, @@ -170,7 +170,7 @@ export interface LegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export interface LabelsOrientationConfig { @@ -180,7 +180,7 @@ export interface LabelsOrientationConfig { } // Arguments to XY chart expression, with computed properties -export interface XYArgs { +export interface XYArgs extends DataLayerArgs { xTitle: string; yTitle: string; yRightTitle: string; @@ -190,7 +190,6 @@ export interface XYArgs { endValue?: EndValue; emphasizeFitting?: boolean; valueLabels: ValueLabelMode; - dataLayers: DataLayerConfigResult[]; referenceLineLayers: ReferenceLineLayerConfigResult[]; annotationLayers: AnnotationLayerConfigResult[]; fittingFunction?: FittingFunction; @@ -296,15 +295,6 @@ export type XYExtendedLayerConfigResult = | ExtendedReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export interface LensMultiTable { - type: typeof MULTITABLE; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; @@ -385,12 +375,6 @@ export type LayeredXyVisFn = ExpressionFunctionDefinition< Promise >; -export type DataLayerFn = ExpressionFunctionDefinition< - typeof DATA_LAYER, - Datatable, - DataLayerArgs, - DataLayerConfigResult ->; export type ExtendedDataLayerFn = ExpressionFunctionDefinition< typeof EXTENDED_DATA_LAYER, Datatable, diff --git a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx index 194bfc2bf5c9d..e84d8c001fb82 100644 --- a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx @@ -8,7 +8,6 @@ import { Datatable } from '@kbn/expressions-plugin/common'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { LensMultiTable } from '../../common'; import { LayerTypes } from '../../common/constants'; import { DataLayerConfig, XYProps } from '../../common/types'; import { mockPaletteOutput, sampleArgs } from '../../common/__mocks__'; @@ -21,151 +20,142 @@ export const chartsActiveCursorService = chartStartContract.activeCursor; export const paletteService = chartPluginMock.createPaletteRegistry(); -export const dateHistogramData: LensMultiTable = { - type: 'lens_multitable', - tables: { - timeLayer: { - type: 'datatable', - rows: [ - { - xAccessorId: 1585758120000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Accessories", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760700000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585761120000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - ], - columns: [ - { - id: 'xAccessorId', - name: 'order_date per minute', - meta: { - type: 'date', +export const dateHistogramData: Datatable = { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + appliedTimeRange: { + from: '2020-04-01T16:14:16.246Z', + to: '2020-04-01T17:15:41.263Z', + }, + params: { field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - appliedTimeRange: { - from: '2020-04-01T16:14:16.246Z', - to: '2020-04-01T17:15:41.263Z', - }, - params: { - field: 'order_date', - timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, - useNormalizedEsInterval: true, - scaleMetricValues: false, - interval: '1m', - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, - }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - }, - params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, - }, - }, + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, }, - { - id: 'yAccessorId', - name: 'Count of records', - meta: { - type: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', }, - params: { id: 'number' }, }, }, - ], + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, }, - }, - dateRange: { - fromDate: new Date('2020-04-01T16:14:16.246Z'), - toDate: new Date('2020-04-01T17:15:41.263Z'), - }, + ], }; export const dateHistogramLayer: DataLayerConfig = { @@ -181,7 +171,7 @@ export const dateHistogramLayer: DataLayerConfig = { seriesType: 'bar_stacked', accessors: ['yAccessorId'], palette: mockPaletteOutput, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, }; export function sampleArgsWithReferenceLine(value: number = 150) { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 3b11ee812da6f..2bcfb37aca2e5 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -305,6 +305,7 @@ exports[`XYChart component it renders area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -846,6 +847,7 @@ exports[`XYChart component it renders bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -1387,6 +1389,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -1928,6 +1931,7 @@ exports[`XYChart component it renders line 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -2469,6 +2473,7 @@ exports[`XYChart component it renders stacked area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3010,6 +3015,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3551,6 +3557,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx index 7c60a6a3a5769..78ac1ed8d10cf 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -7,151 +7,150 @@ */ import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; import { EuiPopover } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ComponentType, ReactWrapper } from 'enzyme'; -import type { DataLayerConfig, LensMultiTable } from '../../common'; +import type { DataLayerConfig } from '../../common'; import { LayerTypes } from '../../common/constants'; import { getLegendAction } from './legend_action'; import { LegendActionPopover } from './legend_action_popover'; import { mockPaletteOutput } from '../../common/__mocks__'; -const tables = { - first: { - type: 'datatable', - rows: [ - { - xAccessorId: 1585758120000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Accessories", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760700000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585761120000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - ], - columns: [ - { - id: 'xAccessorId', - name: 'order_date per minute', - meta: { - type: 'date', - field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - params: { - field: 'order_date', - timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, - useNormalizedEsInterval: true, - scaleMetricValues: false, - interval: '1m', - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, +const table: Datatable = { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', - field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, + }, + params: { + id: 'terms', params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', }, }, }, }, - { - id: 'yAccessorId', - name: 'Count of records', - meta: { - type: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, - }, - params: { id: 'number' }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, }, + params: { id: 'number' }, }, - ], - }, -} as LensMultiTable['tables']; + }, + ], +}; const sampleLayer: DataLayerConfig = { layerId: 'first', @@ -166,7 +165,7 @@ const sampleLayer: DataLayerConfig = { yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, - table: tables.first, + table, }; describe('getLegendAction', function () { @@ -228,7 +227,7 @@ describe('getLegendAction', function () { { column: 1, row: 1, - table: tables.first, + table, value: "Women's Accessories", }, ], diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index de67e814d5b78..48f2393935413 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -57,6 +57,7 @@ import { } from '../../common/types'; import { DataLayers } from './data_layers'; import { Annotations } from './annotations'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -772,7 +773,7 @@ describe('XYChart component', () => { expect(onSelectRange).toHaveBeenCalledWith({ column: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, range: [1585757732783, 1585758880838], }); }); @@ -968,7 +969,7 @@ describe('XYChart component', () => { { column: 0, row: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, value: 1585758120000, }, ], @@ -2377,6 +2378,37 @@ describe('XYChart component', () => { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('computes correct legend sizes', () => { + const { args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: LegendSize.AUTO }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: undefined }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + test('it should apply the fitting function to all non-bar series', () => { const data: Datatable = createSampleDatatableWithRows([ { a: 1, b: 2, c: 'I', d: 'Foo' }, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index db653861a337e..6e3f142996949 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -33,6 +33,10 @@ import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; import { ChartsPluginSetup, ChartsPluginStart, useActiveCursor } from '@kbn/charts-plugin/public'; import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; import { @@ -506,7 +510,7 @@ export function XYChart({ : legend.isVisible } legendPosition={legend?.isInside ? legendInsideParams : legend.position} - legendSize={legend.legendSize} + legendSize={LegendSizeToPixels[legend.legendSize ?? DEFAULT_LEGEND_SIZE]} theme={{ ...chartTheme, barSeriesStyle: { diff --git a/src/plugins/chart_expressions/expression_xy/public/plugin.ts b/src/plugins/chart_expressions/expression_xy/public/plugin.ts index 5e68d2c621894..5c27da6b82b28 100755 --- a/src/plugins/chart_expressions/expression_xy/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/public/plugin.ts @@ -17,7 +17,6 @@ import { ExpressionXyPluginSetup, ExpressionXyPluginStart, SetupDeps } from './t import { xyVisFunction, layeredXyVisFunction, - dataLayerFunction, extendedDataLayerFunction, yAxisConfigFunction, extendedYAxisConfigFunction, @@ -59,7 +58,6 @@ export class ExpressionXyPlugin { expressions.registerFunction(extendedYAxisConfigFunction); expressions.registerFunction(legendConfigFunction); expressions.registerFunction(gridlinesConfigFunction); - expressions.registerFunction(dataLayerFunction); expressions.registerFunction(extendedDataLayerFunction); expressions.registerFunction(axisExtentConfigFunction); expressions.registerFunction(tickLabelsConfigFunction); diff --git a/src/plugins/chart_expressions/expression_xy/server/plugin.ts b/src/plugins/chart_expressions/expression_xy/server/plugin.ts index 37252a7296580..cefde5d38a5f4 100755 --- a/src/plugins/chart_expressions/expression_xy/server/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/server/plugin.ts @@ -15,7 +15,6 @@ import { extendedYAxisConfigFunction, legendConfigFunction, gridlinesConfigFunction, - dataLayerFunction, axisExtentConfigFunction, tickLabelsConfigFunction, annotationLayerFunction, @@ -37,7 +36,6 @@ export class ExpressionXyPlugin expressions.registerFunction(extendedYAxisConfigFunction); expressions.registerFunction(legendConfigFunction); expressions.registerFunction(gridlinesConfigFunction); - expressions.registerFunction(dataLayerFunction); expressions.registerFunction(extendedDataLayerFunction); expressions.registerFunction(axisExtentConfigFunction); expressions.registerFunction(tickLabelsConfigFunction); diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 3d0cc2b47b215..ec35fa85d59a1 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -19,7 +19,7 @@ import { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/pu import { Datatable } from '@kbn/expressions-plugin/public'; export interface ClickTriggerEvent { - name: 'filterBucket'; + name: 'filter'; data: ValueClickContext['data']; } @@ -214,7 +214,7 @@ export const getFilterFromChartClickEventFn = }); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, @@ -250,7 +250,7 @@ export const getFilterFromSeriesFn = })); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index f3aedf36b66a7..c95a2308c3965 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -119,13 +119,12 @@ export function DashboardApp({ <> {isCompleteDashboardAppState(dashboardAppState) && ( <> - {!printMode && ( - - )} + {dashboardAppState.savedDashboard.outcome === 'conflict' && dashboardAppState.savedDashboard.id && diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss b/src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss index a451178cc46b0..cd9c41f392a0b 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss +++ b/src/plugins/dashboard/public/application/embeddable/viewport/_print_viewport.scss @@ -1,9 +1,68 @@ -.printViewport { - &__vis { - height: 600px; // These values might need to be passed in as dimensions for the report. I.e., print should use layout dimensions. - width: 975px; +@import './print_media/styling/index'; - // Some vertical space between vis, but center horizontally - margin: 10px auto; +$visualisationsPerPage: 2; +$visPadding: 4mm; + +/* +We set the same visual padding on the browser and print versions of the UI so that +we don't hit a race condition where padding is being updated while the print image +is being formed. This can result in parts of the vis being cut out. +*/ +@mixin visualizationPadding { + // Open space from page margin + padding-left: $visPadding; + padding-right: $visPadding; + + // Last vis on the page + &:nth-child(#{$visualisationsPerPage}n) { + page-break-after: always; + padding-top: $visPadding; + padding-bottom: $visPadding; + } + + &:last-child { + page-break-after: avoid; + } +} + +@media screen, projection { + .printViewport { + &__vis { + @include visualizationPadding(); + + & .embPanel__header button { + display: none; + } + + margin: $euiSizeL auto; + height: calc(#{$a4PageContentHeight} / #{$visualisationsPerPage}); + width: $a4PageContentWidth; + padding: $visPadding; + } + } +} + +@media print { + .printViewport { + &__vis { + @include visualizationPadding(); + + height: calc(#{$a4PageContentHeight} / #{$visualisationsPerPage}); + width: $a4PageContentWidth; + + & .euiPanel { + box-shadow: none !important; + } + + & .embPanel__header button { + display: none; + } + + page-break-inside: avoid; + + & * { + overflow: hidden !important; + } + } } } diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/README.md b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/README.md new file mode 100644 index 0000000000000..9bd8bfc3a0944 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/README.md @@ -0,0 +1,7 @@ +# Print media + +The code here is designed to be movable outside the domain of Dashboard. Currently, +the components and styles are only used by Dashboard but we may choose to move them to, +for example, a Kibana package in the future. + +Any changes to this code must be tested by generating a print-optimized PDF in dashboard. \ No newline at end of file diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_index.scss b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_index.scss new file mode 100644 index 0000000000000..16c4dd85ea45e --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_index.scss @@ -0,0 +1,52 @@ +@import './vars'; + +/* +This styling contains utility and minimal layout styles to help plugins create +print-ready HTML. + +Observations: +1. We currently do not control the user-agent's header and footer content + (including the style of fonts) for client-side printing. + +2. Page box model is quite different from what we have in browsers - page + margins define where the "no-mans-land" exists for actual content. Moving + content into this space by, for example setting negative margins resulted + in slightly unpredictable behaviour because the browser wants to either + move this content to another page or it may get split across two + pages. + +3. page-break-* is your friend! +*/ + +// Currently we cannot control or style the content the browser places in +// margins, this might change in the future: +// See https://drafts.csswg.org/css-page-3/#margin-boxes +@page { + size: A4; + orientation: portrait; + margin: 0; + margin-top: $a4PageHeaderHeight; + margin-bottom: $a4PageFooterHeight; +} + +@media print { + + html { + background-color: #FFF; + } + + // It is good practice to show the full URL in the final, printed output + a[href]:after { + content: ' [' attr(href) ']'; + } + + figure { + page-break-inside: avoid; + } + + * { + -webkit-print-color-adjust: exact !important; /* Chrome, Safari, Edge */ + color-adjust: exact !important; /*Firefox*/ + } + +} diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_vars.scss b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_vars.scss new file mode 100644 index 0000000000000..d7addc7afb261 --- /dev/null +++ b/src/plugins/dashboard/public/application/embeddable/viewport/print_media/styling/_vars.scss @@ -0,0 +1,10 @@ + +$a4PageHeight: 297mm; +$a4PageWidth: 210mm; +$a4PageMargin: 0; +$a4PagePadding: 0; +$a4PageHeaderHeight: 15mm; +$a4PageFooterHeight: 20mm; + +$a4PageContentHeight: $a4PageHeight - $a4PageHeaderHeight - $a4PageFooterHeight; +$a4PageContentWidth: $a4PageWidth; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index a103c88843664..50c40e4863bee 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -183,8 +183,7 @@ export const useDashboardAppState = ({ savedDashboard, }); - // Backwards compatible way of detecting that we are taking a screenshot - const legacyPrintLayoutDetected = + const printLayoutDetected = screenshotModeService?.isScreenshotMode() && screenshotModeService.getScreenshotContext('layout') === 'print'; @@ -194,8 +193,7 @@ export const useDashboardAppState = ({ ...initialDashboardStateFromUrl, ...forwardedAppState, - // if we are in legacy print mode, dashboard needs to be in print viewMode - ...(legacyPrintLayoutDetected ? { viewMode: ViewMode.PRINT } : {}), + ...(printLayoutDetected ? { viewMode: ViewMode.PRINT } : {}), // if there is an incoming embeddable, dashboard always needs to be in edit mode to receive it. ...(incomingEmbeddable ? { viewMode: ViewMode.EDIT } : {}), diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 7095ad34cd189..5cbbd30c79a24 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -82,6 +82,7 @@ export interface DashboardTopNavProps { dashboardAppState: CompleteDashboardAppState; embedSettings?: DashboardEmbedSettings; redirectTo: DashboardRedirect; + printMode: boolean; } const LabsFlyout = withSuspense(LazyLabsFlyout, null); @@ -90,6 +91,7 @@ export function DashboardTopNav({ dashboardAppState, embedSettings, redirectTo, + printMode, }: DashboardTopNavProps) { const { core, @@ -488,7 +490,9 @@ export function DashboardTopNav({ const isFullScreenMode = dashboardState.fullScreenMode; const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); - const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput)); + const showQueryInput = shouldShowNavBarComponent( + Boolean(embedSettings?.forceShowQueryInput || printMode) + ); const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showQueryBar = showQueryInput || showDatePicker || showFilterBar; @@ -535,6 +539,7 @@ export function DashboardTopNav({ useDefaultBehaviors: true, savedQuery: state.savedQuery, savedQueryId: dashboardState.savedQuery, + visible: printMode !== true, onQuerySubmit: (_payload, isUpdate) => { if (isUpdate === false) { dashboardAppState.$triggerDashboardRefresh.next({ force: true }); @@ -585,10 +590,10 @@ export function DashboardTopNav({ return ( <> - {isLabsEnabled && isLabsShown ? ( + {!printMode && isLabsEnabled && isLabsShown ? ( setIsLabsShown(false)} /> ) : null} - {dashboardState.viewMode !== ViewMode.VIEW ? ( + {dashboardState.viewMode !== ViewMode.VIEW && !printMode ? ( <> diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts index fe35f3ea90008..beddf6b623110 100644 --- a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts @@ -7,6 +7,8 @@ */ import { i18n } from '@kbn/i18n'; +import { buildEsQuery, buildQueryFilter } from '@kbn/es-query'; +import { getEsQueryConfig } from '../../..'; import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; @@ -27,7 +29,11 @@ const filteredMetricTitle = i18n.translate('data.search.aggs.metrics.filteredMet defaultMessage: 'Filtered metric', }); -export const getFilteredMetricAgg = () => { +export interface FiltersMetricAggDependencies { + getConfig: (key: string) => T; +} + +export const getFilteredMetricAgg = ({ getConfig }: FiltersMetricAggDependencies) => { const { subtype, params, getSerializedFormat } = siblingPipelineAggHelper; return new MetricAggType({ @@ -39,6 +45,19 @@ export const getFilteredMetricAgg = () => { params: [...params(['filter'])], hasNoDslParams: true, getSerializedFormat, + createFilter: (agg, inputState) => { + const esQueryConfigs = getEsQueryConfig({ get: getConfig }); + return buildQueryFilter( + buildEsQuery( + agg.getIndexPattern(), + [agg.params.customBucket.params.filter], + [], + esQueryConfigs + ), + agg.getIndexPattern().id!, + agg.params.customBucket.params.filter.query + ); + }, getValue(agg, bucket) { const customMetric = agg.getParam('customMetric'); const customBucket = agg.getParam('customBucket'); diff --git a/src/plugins/data/common/search/aggs/metrics/lib/create_filter.ts b/src/plugins/data/common/search/aggs/metrics/lib/create_filter.ts new file mode 100644 index 0000000000000..a859e189ccfe5 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/lib/create_filter.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { buildExistsFilter } from '@kbn/es-query'; +import { AggConfig } from '../../agg_config'; +import { IMetricAggConfig } from '../metric_agg_type'; + +export const createMetricFilter = ( + aggConfig: TMetricAggConfig, + key: string +) => { + const indexPattern = aggConfig.getIndexPattern(); + if (aggConfig.getField()) { + return buildExistsFilter(aggConfig.getField(), indexPattern); + } +}; diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts index c96ba217779a6..59bbe377ba28a 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts @@ -13,6 +13,7 @@ import { AggConfig } from '../agg_config'; import { METRIC_TYPES } from './metric_agg_types'; import { BaseParamType, FieldTypes } from '../param_types'; import { AggGroupNames } from '../agg_groups'; +import { createMetricFilter } from './lib/create_filter'; export interface IMetricAggConfig extends AggConfig { type: InstanceType; @@ -48,6 +49,9 @@ export class MetricAggType {}; constructor(config: MetricAggTypeConfig) { + if (!config.createFilter) { + config.createFilter = createMetricFilter; + } super(config); this.params.push( diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index 955d69509cf01..7b715bb56a74c 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -156,9 +156,9 @@ function DiscoverDocumentsComponent({ )} {!isLegacy && ( -
- <> - + <> + +
- -
+
+ )} ); diff --git a/src/plugins/expressions/common/executor/executor.test.ts b/src/plugins/expressions/common/executor/executor.test.ts index 35f0b9c13aa1a..ea7116a5307ba 100644 --- a/src/plugins/expressions/common/executor/executor.test.ts +++ b/src/plugins/expressions/common/executor/executor.test.ts @@ -141,13 +141,15 @@ describe('Executor', () => { inject: (state: ExpressionAstFunction['arguments']) => { return injectFn(state); }, - migrations: { - '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { - return migrateFn(state, version); - }) as unknown as MigrateFunction, - '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { - return migrateFn(state, version); - }) as unknown as MigrateFunction, + migrations: () => { + return { + '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { + return migrateFn(state, version); + }) as unknown as MigrateFunction, + '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => { + return migrateFn(state, version); + }) as unknown as MigrateFunction, + }; }, fn: jest.fn(), }; diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts index 0a5e8d388fe00..4071f8f7f003f 100644 --- a/src/plugins/expressions/common/executor/executor.ts +++ b/src/plugins/expressions/common/executor/executor.ts @@ -336,7 +336,11 @@ export class Executor = Record Object.keys(fn.migrations)) + .map((fn) => { + const migrations = + typeof fn.migrations === 'function' ? fn.migrations() : fn.migrations || {}; + return Object.keys(migrations); + }) .flat(1) ); diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 4ea014457fd07..08e830fba4155 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -2,17 +2,18 @@ This plugin registers the Platform Usage Collectors in Kibana. -| Collector name | Description | Extended documentation | -|----------------|:------------|:----------------------:| -| **Application Usage** | Measures how popular an App in Kibana is by reporting the on-screen time and the number of general clicks that happen in it. | [Link](./server/collectors/application_usage/README.md) | -| **Core Metrics** | Collects the usage reported by the core APIs | - | -| **Config Usage** | Reports the non-default values set via `kibana.yml` config file or CLI options. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/config_usage/README.md) | -| **User-changed UI Settings** | Reports all the UI Settings that have been overwritten by the user. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/management/README.md) | -| **CSP configuration** | Reports the key values regarding the CSP configuration. | - | -| **Kibana** | It reports the number of Saved Objects per type. It is limited to `dashboard`, `visualization`, `search`, `index-pattern`, `graph-workspace`.
It exists for legacy purposes, and may still be used by Monitoring via Metricbeat. | - | -| **Saved Objects Counts** | Number of Saved Objects per type. | - | -| **Localization data** | Localization settings: setup locale and installed translation files. | - | -| **Ops stats** | Operation metrics from the system. | - | -| **UI Counters** | Daily aggregation of the number of times an event occurs in the UI. | [Link](../usage_collection/README.mdx#ui-counters) | -| **UI Metrics** | Deprecated. Old form of UI Counters. It reports the _count of the repetitions since the cluster's first start_ of any UI events that may have happened. | - | -| **Usage Counters** | Daily aggregation of the number of times an event occurs on the Server. | [Link](../usage_collection/README.mdx#usage-counters) | +| Collector name | Description | Extended documentation | +|--------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------:| +| **Application Usage** | Measures how popular an App in Kibana is by reporting the on-screen time and the number of general clicks that happen in it. | [Link](./server/collectors/application_usage/README.md) | +| **Core Metrics** | Collects the usage reported by the core APIs | - | +| **Config Usage** | Reports the non-default values set via `kibana.yml` config file or CLI options. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/config_usage/README.md) | +| **User-changed UI Settings** | Reports all the UI Settings that have been overwritten by the user. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/management/README.md) | +| **CSP configuration** | Reports the key values regarding the CSP configuration. | - | +| **Kibana** | It reports the number of Saved Objects per type. It is limited to `dashboard`, `visualization`, `search`, `index-pattern`, `graph-workspace`.
It exists for legacy purposes, and may still be used by Monitoring via Metricbeat. | - | +| **Saved Objects Counts** | Number of Saved Objects per type. | - | +| **Localization data** | Localization settings: setup locale and installed translation files. | - | +| **Ops stats** | Operation metrics from the system. | - | +| **UI Counters** | Daily aggregation of the number of times an event occurs in the UI. | [Link](../usage_collection/README.mdx#ui-counters) | +| **UI Metrics** | Deprecated. Old form of UI Counters. It reports the _count of the repetitions since the cluster's first start_ of any UI events that may have happened. | - | +| **Usage Counters** | Daily aggregation of the number of times an event occurs on the Server. | [Link](../usage_collection/README.mdx#usage-counters) | +| **Event-based Telemetry Success Counters** | Using the UI and Usage Counters APIs, it reports the stats coming out of the `core.analytics.telemetryCounters$` observable. | [Browser](./public/ebt_counters/README.md) and [Server](./server/ebt_counters/README.md) | diff --git a/src/plugins/kibana_usage_collection/kibana.json b/src/plugins/kibana_usage_collection/kibana.json index 39b55e5c6dd94..41fc5c6c37b78 100644 --- a/src/plugins/kibana_usage_collection/kibana.json +++ b/src/plugins/kibana_usage_collection/kibana.json @@ -6,7 +6,7 @@ }, "version": "kibana", "server": true, - "ui": false, + "ui": true, "requiredPlugins": [ "usageCollection" ], diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/README.md b/src/plugins/kibana_usage_collection/public/ebt_counters/README.md new file mode 100644 index 0000000000000..d30aa0661e977 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/README.md @@ -0,0 +1,14 @@ +# Event-based Telemetry Success Counters (browser-side) + +Using the UI Counters API, it reports the stats coming from the `core.analytics.telemetryCounters$` observable. It allows us to track the success of the EBT client on the browser. + +## Field mappings + +As the number of fields available in the Usage API is reduced, this collection merges some fields to be able to report it. + +| UI Counter field | Telemetry Counter fields | +|------------------|--------------------------------------------------------------------------------------------------| +| `appName` | Concatenation of the string `'ebt_counters.'` and the `source` (`'client'` or the shipper name). | +| `eventName` | Matches the `eventType`. | +| `counterType` | Concatenation of the `type` and the `code` (i.e.: `'succeeded_200'`). | +| `total` | Matches the value in `count`. | \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/index.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/index.ts new file mode 100644 index 0000000000000..24deee4afb5d0 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/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 { registerEbtCounters } from './register_ebt_counters'; diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts new file mode 100644 index 0000000000000..2bf67d02fe110 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { TelemetryCounter } from '@kbn/analytics-client'; +import { TelemetryCounterType } from '@kbn/analytics-client'; +import { coreMock } from '@kbn/core/public/mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/public/mocks'; +import { registerEbtCounters } from './register_ebt_counters'; + +describe('registerEbtCounters', () => { + let core: ReturnType; + let usageCollection: ReturnType; + let internalListener: (counter: TelemetryCounter) => void; + let telemetryCounter$Spy: jest.SpyInstance; + + beforeEach(() => { + core = coreMock.createSetup(); + usageCollection = usageCollectionPluginMock.createSetupContract(); + telemetryCounter$Spy = jest + .spyOn(core.analytics.telemetryCounter$, 'subscribe') + .mockImplementation(((listener) => { + internalListener = listener as (counter: TelemetryCounter) => void; + }) as typeof core.analytics.telemetryCounter$['subscribe']); + }); + + test('it subscribes to `analytics.telemetryCounters$`', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + }); + + test('it reports a UI counter whenever a counter is emitted', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.reportUiCounter).toHaveBeenCalledTimes(1); + expect(usageCollection.reportUiCounter).toHaveBeenCalledWith( + 'ebt_counters.test-shipper', + 'succeeded_test-code', + 'test-event', + 1 + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.ts new file mode 100644 index 0000000000000..483e00d8d03fe --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { AnalyticsServiceSetup } from '@kbn/core/public'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; + +export function registerEbtCounters( + analytics: AnalyticsServiceSetup, + usageCollection: UsageCollectionSetup +) { + // The client should complete telemetryCounter$ when shutting down. We shouldn't need to pipe(takeUntil(stop$)). + analytics.telemetryCounter$.subscribe(({ type, source, event_type: eventType, code, count }) => { + usageCollection.reportUiCounter(`ebt_counters.${source}`, `${type}_${code}`, eventType, count); + }); +} diff --git a/src/plugins/kibana_usage_collection/public/index.ts b/src/plugins/kibana_usage_collection/public/index.ts new file mode 100644 index 0000000000000..5474b8db0b27f --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { KibanaUsageCollectionPlugin } from './plugin'; + +export function plugin() { + return new KibanaUsageCollectionPlugin(); +} diff --git a/src/plugins/kibana_usage_collection/public/plugin.ts b/src/plugins/kibana_usage_collection/public/plugin.ts new file mode 100644 index 0000000000000..2b7a4b868b76a --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/plugin.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import type { CoreSetup, Plugin } from '@kbn/core/public'; +import { registerEbtCounters } from './ebt_counters'; + +interface KibanaUsageCollectionPluginsDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class KibanaUsageCollectionPlugin implements Plugin { + public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + registerEbtCounters(coreSetup.analytics, usageCollection); + } + + public start() {} + + public stop() {} +} diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/README.md b/src/plugins/kibana_usage_collection/server/ebt_counters/README.md new file mode 100644 index 0000000000000..46762148a952c --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/README.md @@ -0,0 +1,14 @@ +# Event-based Telemetry Success Counters (server-side) + +Using the Usage Counters API, it reports the stats coming from the `core.analytics.telemetryCounters$` observable. It allows us to track the success of the EBT client on the server side. + +## Field mappings + +As the number of fields available in the Usage API is reduced, this collection merges some fields to be able to report it. + +| Usage Counter field | Telemetry Counter fields | +|---------------------|--------------------------------------------------------------------------------------------------| +| `domainId` | Concatenation of the string `'ebt_counters.'` and the `source` (`'client'` or the shipper name). | +| `counterName` | Matches the `eventType`. | +| `counterType` | Concatenation of the `type` and the `code` (i.e.: `'succeeded_200'`). | +| `total` | Matches the value in `count`. | \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/index.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/index.ts new file mode 100644 index 0000000000000..24deee4afb5d0 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/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 { registerEbtCounters } from './register_ebt_counters'; diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.test.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.test.ts new file mode 100644 index 0000000000000..ddeb85ee1be02 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright 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 { TelemetryCounter } from '@kbn/analytics-client'; +import { TelemetryCounterType } from '@kbn/analytics-client'; +import { coreMock } from '@kbn/core/server/mocks'; +import { createUsageCollectionSetupMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { registerEbtCounters } from './register_ebt_counters'; + +describe('registerEbtCounters', () => { + let core: ReturnType; + let usageCollection: ReturnType; + let internalListener: (counter: TelemetryCounter) => void; + let telemetryCounter$Spy: jest.SpyInstance; + + beforeEach(() => { + core = coreMock.createSetup(); + usageCollection = createUsageCollectionSetupMock(); + telemetryCounter$Spy = jest + .spyOn(core.analytics.telemetryCounter$, 'subscribe') + .mockImplementation(((listener) => { + internalListener = listener as (counter: TelemetryCounter) => void; + }) as typeof core.analytics.telemetryCounter$['subscribe']); + }); + + test('it subscribes to `analytics.telemetryCounters$`', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + }); + + test('it creates a new usageCounter when it does not exist', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledTimes(1); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledWith('ebt_counters.test-shipper'); + expect(usageCollection.createUsageCounter).toHaveBeenCalledTimes(1); + expect(usageCollection.createUsageCounter).toHaveBeenCalledWith('ebt_counters.test-shipper'); + }); + + test('it reuses the usageCounter when it already exists', () => { + const incrementCounterMock = jest.fn(); + usageCollection.getUsageCounterByType.mockReturnValue({ + incrementCounter: incrementCounterMock, + }); + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledTimes(1); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledWith('ebt_counters.test-shipper'); + expect(usageCollection.createUsageCounter).toHaveBeenCalledTimes(0); + expect(incrementCounterMock).toHaveBeenCalledTimes(1); + expect(incrementCounterMock).toHaveBeenCalledWith({ + counterName: 'test-event', + counterType: `succeeded_test-code`, + incrementBy: 1, + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts new file mode 100644 index 0000000000000..ed2100dccf929 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { AnalyticsServiceSetup } from '@kbn/core/server'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; + +export function registerEbtCounters( + analytics: AnalyticsServiceSetup, + usageCollection: UsageCollectionSetup +) { + // The client should complete telemetryCounter$ when shutting down. We shouldn't need to pipe(takeUntil(stop$)). + analytics.telemetryCounter$.subscribe(({ type, source, event_type: eventType, code, count }) => { + // We create one counter per source ('client'|). + const domainId = `ebt_counters.${source}`; + const usageCounter = + usageCollection.getUsageCounterByType(domainId) ?? + usageCollection.createUsageCounter(domainId); + + usageCounter.incrementCounter({ + counterName: eventType, // the name of the event + counterType: `${type}_${code}`, // e.g. 'succeeded_200' + incrementBy: count, + }); + }); +} diff --git a/src/plugins/kibana_usage_collection/server/mocks.ts b/src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts similarity index 83% rename from src/plugins/kibana_usage_collection/server/mocks.ts rename to src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts index 7df27a3719e92..a21b2b007f5e9 100644 --- a/src/plugins/kibana_usage_collection/server/mocks.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts @@ -16,3 +16,9 @@ export const detectCloudServiceMock = mock.detectCloudService; jest.doMock('./collectors/cloud/detector', () => ({ CloudDetector: jest.fn().mockImplementation(() => mock), })); + +export const registerEbtCountersMock = jest.fn(); + +jest.doMock('./ebt_counters', () => ({ + registerEbtCounters: registerEbtCountersMock, +})); diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index a6604ac0bc1cd..ef26492c2d6fd 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -15,7 +15,7 @@ import { CollectorOptions, createUsageCollectionSetupMock, } from '@kbn/usage-collection-plugin/server/mocks'; -import { cloudDetailsMock } from './mocks'; +import { cloudDetailsMock, registerEbtCountersMock } from './plugin.test.mocks'; import { plugin } from '.'; describe('kibana_usage_collection', () => { @@ -44,6 +44,9 @@ describe('kibana_usage_collection', () => { expect(coreSetup.coreUsageData.registerUsageCounter).toHaveBeenCalled(); + expect(registerEbtCountersMock).toHaveBeenCalledTimes(1); + expect(registerEbtCountersMock).toHaveBeenCalledWith(coreSetup.analytics, usageCollection); + await expect( Promise.all( usageCollectors.map(async (usageCollector) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 34bf029311307..10f05ccbac945 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -21,6 +21,7 @@ import type { CoreUsageDataStart, } from '@kbn/core/server'; import { SavedObjectsClient, EventLoopDelaysMonitor } from '@kbn/core/server'; +import { registerEbtCounters } from './ebt_counters'; import { startTrackingEventLoopDelaysUsage, startTrackingEventLoopDelaysThreshold, @@ -68,6 +69,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + registerEbtCounters(coreSetup.analytics, usageCollection); usageCollection.createUsageCounter('uiCounters'); this.eventLoopUsageCounter = usageCollection.createUsageCounter('eventLoop'); coreSetup.coreUsageData.registerUsageCounter(usageCollection.createUsageCounter('core')); diff --git a/src/plugins/kibana_usage_collection/tsconfig.json b/src/plugins/kibana_usage_collection/tsconfig.json index e57d6e25db8cd..d9a1e648995bb 100644 --- a/src/plugins/kibana_usage_collection/tsconfig.json +++ b/src/plugins/kibana_usage_collection/tsconfig.json @@ -9,6 +9,7 @@ }, "include": [ "common/*", + "public/**/**/*", "server/**/**/*", "../../../typings/*" ], diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index db6cf1bc3d006..5ae2a4498b55b 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -4,6 +4,12 @@ } } +.kbnTopNavMenu__wrapper { + &--hidden { + display: none; + } +} + .kbnTopNavMenu__badgeWrapper { display: flex; align-items: baseline; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 62dc67a3ee941..86c83a6b48be5 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -28,6 +28,7 @@ export type TopNavMenuProps = StatefulSearchBarProps & showFilterBar?: boolean; unifiedSearch?: UnifiedSearchPublicPluginStart; className?: string; + visible?: boolean; /** * If provided, the menu part of the component will be rendered as a portal inside the given mount point. * @@ -105,9 +106,11 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderLayout() { - const { setMenuMountPoint } = props; + const { setMenuMountPoint, visible } = props; const menuClassName = classNames('kbnTopNavMenu', props.className); - const wrapperClassName = 'kbnTopNavMenu__wrapper'; + const wrapperClassName = classNames('kbnTopNavMenu__wrapper', { + 'kbnTopNavMenu__wrapper--hidden': visible === false, + }); if (setMenuMountPoint) { return ( <> diff --git a/src/plugins/newsfeed/README.md b/src/plugins/newsfeed/README.md index d8a0bffb4ed0b..398578092425d 100644 --- a/src/plugins/newsfeed/README.md +++ b/src/plugins/newsfeed/README.md @@ -1,4 +1,4 @@ # newsfeed The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. -Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. +Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. diff --git a/src/plugins/newsfeed/common/constants.ts b/src/plugins/newsfeed/common/constants.ts index 6ba5e07ea873e..f4467dfe35011 100644 --- a/src/plugins/newsfeed/common/constants.ts +++ b/src/plugins/newsfeed/common/constants.ts @@ -8,5 +8,4 @@ export const NEWSFEED_FALLBACK_LANGUAGE = 'en'; export const NEWSFEED_DEFAULT_SERVICE_BASE_URL = 'https://feeds.elastic.co'; -export const NEWSFEED_DEV_SERVICE_BASE_URL = 'https://feeds-staging.elastic.co'; export const NEWSFEED_DEFAULT_SERVICE_PATH = '/kibana/v{VERSION}.json'; diff --git a/src/plugins/newsfeed/server/config.ts b/src/plugins/newsfeed/server/config.ts index f14f3452761e1..f371da244f871 100644 --- a/src/plugins/newsfeed/server/config.ts +++ b/src/plugins/newsfeed/server/config.ts @@ -10,19 +10,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { NEWSFEED_DEFAULT_SERVICE_PATH, NEWSFEED_DEFAULT_SERVICE_BASE_URL, - NEWSFEED_DEV_SERVICE_BASE_URL, } from '../common/constants'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), service: schema.object({ pathTemplate: schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_PATH }), - urlRoot: schema.conditional( - schema.contextRef('prod'), - schema.literal(true), // Point to staging if it's not a production release - schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_BASE_URL }), - schema.string({ defaultValue: NEWSFEED_DEV_SERVICE_BASE_URL }) - ), + urlRoot: schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_BASE_URL }), }), mainInterval: schema.duration({ defaultValue: '2m' }), // (2min) How often to retry failed fetches, and/or check if newsfeed items need to be refreshed from remote fetchInterval: schema.duration({ defaultValue: '1d' }), // (1day) How often to fetch remote and reset the last fetched time diff --git a/src/plugins/unified_search/kibana.json b/src/plugins/unified_search/kibana.json index b947141a0c68a..07e438ab52174 100755 --- a/src/plugins/unified_search/kibana.json +++ b/src/plugins/unified_search/kibana.json @@ -9,7 +9,7 @@ }, "server": true, "ui": true, - "requiredPlugins": ["dataViews", "data", "uiActions"], + "requiredPlugins": ["dataViews", "data", "uiActions", "screenshotMode"], "requiredBundles": ["kibanaUtils", "kibanaReact", "data"], "serviceFolders": ["autocomplete"], "configPath": ["unifiedSearch"] diff --git a/src/plugins/unified_search/public/plugin.ts b/src/plugins/unified_search/public/plugin.ts index 26727b56094a0..08f07e507d96b 100755 --- a/src/plugins/unified_search/public/plugin.ts +++ b/src/plugins/unified_search/public/plugin.ts @@ -55,7 +55,7 @@ export class UnifiedSearchPublicPlugin public start( core: CoreStart, - { data, dataViews, uiActions }: UnifiedSearchStartDependencies + { data, dataViews, uiActions, screenshotMode }: UnifiedSearchStartDependencies ): UnifiedSearchPublicPluginStart { setTheme(core.theme); setOverlays(core.overlays); @@ -68,6 +68,7 @@ export class UnifiedSearchPublicPlugin data, storage: this.storage, usageCollection: this.usageCollection, + isScreenshotMode: Boolean(screenshotMode?.isScreenshotMode()), }); uiActions.addTriggerAction( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx index 7a04b92c7e063..3d41a9145dffa 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx @@ -120,7 +120,7 @@ describe('Querybar Menu component', () => { expect(component.find('[data-test-subj="queryBarMenuPanel"]')).toBeTruthy(); }); - it('should render the saved filter sets panels if the showQueryInput prop is true but disabled', async () => { + it('should render the saved saved queries panels if the showQueryInput prop is true but disabled', async () => { const newProps = { ...props, openQueryBarMenu: true, @@ -140,7 +140,7 @@ describe('Querybar Menu component', () => { expect(loadFilterSetButton.first().prop('disabled')).toBe(true); }); - it('should render the filter sets panels if the showFilterBar is true but disabled', async () => { + it('should render the saved queries panels if the showFilterBar is true but disabled', async () => { const newProps = { ...props, openQueryBarMenu: true, diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx index 2b34aef33eeee..810d0a64d0251 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx @@ -94,7 +94,7 @@ export function QueryBarMenu({ }; const buttonLabel = i18n.translate('unifiedSearch.filter.options.filterSetButtonLabel', { - defaultMessage: 'Filter set menu', + defaultMessage: 'Saved query menu', }); const button = ( diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx index de70e66fda5fc..548d7f24d5da7 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx @@ -249,10 +249,10 @@ export function QueryBarMenuPanels({ { name: savedQuery ? i18n.translate('unifiedSearch.filter.options.loadOtherFilterSetLabel', { - defaultMessage: 'Load other filter set', + defaultMessage: 'Load other saved query', }) : i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { - defaultMessage: 'Load filter set', + defaultMessage: 'Load saved query', }), panel: 4, width: 350, @@ -266,7 +266,7 @@ export function QueryBarMenuPanels({ defaultMessage: 'Save as new', }) : i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', { - defaultMessage: 'Save filter set', + defaultMessage: 'Save saved query', }), icon: 'save', disabled: @@ -331,7 +331,13 @@ export function QueryBarMenuPanels({ size="s" data-test-subj="savedQueryTitle" > - {savedQuery ? savedQuery.attributes.title : 'Filter set'} + + {savedQuery + ? savedQuery.attributes.title + : i18n.translate('unifiedSearch.search.searchBar.savedQuery', { + defaultMessage: 'Saved query', + })} + {savedQuery && savedQueryHasChanged && Boolean(showSaveQuery) && hasFiltersOrQuery && ( @@ -397,7 +403,7 @@ export function QueryBarMenuPanels({ { id: 1, title: i18n.translate('unifiedSearch.filter.options.saveCurrentFilterSetLabel', { - defaultMessage: 'Save current filter set', + defaultMessage: 'Save current saved query', }), disabled: !Boolean(showSaveQuery), content:
{saveAsNewQueryFormComponent}
, @@ -483,7 +489,7 @@ export function QueryBarMenuPanels({ { id: 4, title: i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { - defaultMessage: 'Load filter set', + defaultMessage: 'Load saved query', }), width: 400, content:
{manageFilterSetComponent}
, diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index bb01338d8d5a0..0bff12ac78798 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -87,6 +87,7 @@ export interface QueryBarTopRowProps { filterBar?: React.ReactNode; showDatePickerAsBadge?: boolean; showSubmitButton?: boolean; + isScreenshotMode?: boolean; } const SharingMetaFields = React.memo(function SharingMetaFields({ @@ -474,6 +475,8 @@ export const QueryBarTopRow = React.memo( ); } + const isScreenshotMode = props.isScreenshotMode === true; + return ( <> - - {renderDataViewsPicker()} - - {renderQueryInput()} - - {shouldShowDatePickerAsBadge() && props.filterBar} - {renderUpdateButton()} - - {!shouldShowDatePickerAsBadge() && props.filterBar} + {!isScreenshotMode && ( + <> + + {renderDataViewsPicker()} + + {renderQueryInput()} + + {shouldShowDatePickerAsBadge() && props.filterBar} + {renderUpdateButton()} + + {!shouldShowDatePickerAsBadge() && props.filterBar} + + )} ); }, diff --git a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx index 186c1f072aedd..008c61b909ce1 100644 --- a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx +++ b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx @@ -203,7 +203,7 @@ export function SaveQueryForm({ disabled={hasErrors} > {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save filter set', + defaultMessage: 'Save saved query', })} diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx index 7c2d0ebd1faad..c7db17ea934d5 100644 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx @@ -140,7 +140,7 @@ describe('Saved query management list component', () => { .find('[data-test-subj="saved-query-management-apply-changes-button"]') .first() .text() - ).toBe('Apply filter set'); + ).toBe('Apply saved query'); const newProps = { ...props, @@ -153,7 +153,7 @@ describe('Saved query management list component', () => { .find('[data-test-subj="saved-query-management-apply-changes-button"]') .first() .text() - ).toBe('Replace with selected filter set'); + ).toBe('Replace with selected saved query'); }); it('should render the modal on delete', async () => { diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx index 7568bb9375fa6..127aa804f77f8 100644 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx @@ -266,7 +266,7 @@ export function SavedQueryManagementList({ placeholder: i18n.translate( 'unifiedSearch.query.queryBar.indexPattern.findFilterSet', { - defaultMessage: 'Find a filter set', + defaultMessage: 'Find a saved query', } ), }} @@ -323,7 +323,7 @@ export function SavedQueryManagementList({ aria-label={i18n.translate( 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', { - defaultMessage: 'Apply filter set', + defaultMessage: 'Apply saved query', } )} data-test-subj="saved-query-management-apply-changes-button" @@ -332,13 +332,13 @@ export function SavedQueryManagementList({ ? i18n.translate( 'unifiedSearch.search.searchBar.savedQueryPopoverReplaceFilterSetLabel', { - defaultMessage: 'Replace with selected filter set', + defaultMessage: 'Replace with selected saved query', } ) : i18n.translate( 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', { - defaultMessage: 'Apply filter set', + defaultMessage: 'Apply saved query', } )} diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index c4e54995b5979..c73aa258863ed 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -26,6 +26,7 @@ interface StatefulSearchBarDeps { data: Omit; storage: IStorageWrapper; usageCollection?: UsageCollectionSetup; + isScreenshotMode?: boolean; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -110,7 +111,13 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { return props.useDefaultBehaviors ? {} : props; }; -export function createSearchBar({ core, storage, data, usageCollection }: StatefulSearchBarDeps) { +export function createSearchBar({ + core, + storage, + data, + usageCollection, + isScreenshotMode = false, +}: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { @@ -197,6 +204,7 @@ export function createSearchBar({ core, storage, data, usageCollection }: Statef {...overrideDefaultBehaviors(props)} dataViewPickerComponentProps={props.dataViewPickerComponentProps} displayStyle={props.displayStyle} + isScreenshotMode={isScreenshotMode} /> ); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.styles.ts b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts index 1072a684eeaad..36d06d1cb9c7f 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.styles.ts +++ b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts @@ -20,5 +20,8 @@ export const searchBarStyles = ({ euiTheme }: UseEuiTheme) => { inPage: css` padding: 0; `, + hidden: css` + display: none; + `, }; }; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index a684e5ba928a8..8c5abc1bf4c2c 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -87,6 +87,7 @@ export interface SearchBarOwnProps { fillSubmitButton?: boolean; dataViewPickerComponentProps?: DataViewPickerProps; showSubmitButton?: boolean; + isScreenshotMode?: boolean; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -341,13 +342,16 @@ class SearchBarUI extends Component { public render() { const { theme } = this.props; + const isScreenshotMode = this.props.isScreenshotMode === true; const styles = searchBarStyles(theme); const cssStyles = [ styles.uniSearchBar, this.props.displayStyle && styles[this.props.displayStyle], + isScreenshotMode && styles.hidden, ]; const classes = classNames('uniSearchBar', { + [`uniSearchBar--hidden`]: isScreenshotMode, [`uniSearchBar--${this.props.displayStyle}`]: this.props.displayStyle, }); @@ -470,6 +474,7 @@ class SearchBarUI extends Component { dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} filterBar={filterBar} + isScreenshotMode={this.props.isScreenshotMode} />
); diff --git a/src/plugins/unified_search/public/types.ts b/src/plugins/unified_search/public/types.ts index 29cf59f41a871..fa0fc9e826e37 100755 --- a/src/plugins/unified_search/public/types.ts +++ b/src/plugins/unified_search/public/types.ts @@ -8,6 +8,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; @@ -29,6 +30,7 @@ export interface UnifiedSearchStartDependencies { fieldFormats: FieldFormatsStart; data: DataPublicPluginStart; uiActions: UiActionsStart; + screenshotMode?: ScreenshotModePluginStart; } /** diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json index e6a56bd65fcc7..240eb7ccab6fa 100644 --- a/src/plugins/vis_default_editor/kibana.json +++ b/src/plugins/vis_default_editor/kibana.json @@ -3,7 +3,16 @@ "version": "kibana", "ui": true, "optionalPlugins": ["visualizations"], - "requiredBundles": ["unifiedSearch", "kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover", "esUiShared"], + "requiredBundles": [ + "unifiedSearch", + "kibanaUtils", + "kibanaReact", + "data", + "fieldFormats", + "discover", + "esUiShared", + "visualizations" + ], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx new file mode 100644 index 0000000000000..3eeb93e6155df --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx @@ -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 React from 'react'; +import { LegendSizeSettings } from './legend_size_settings'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { shallow } from 'enzyme'; + +describe('legend size settings', () => { + it('select is disabled if not vertical legend', () => { + const instance = shallow( + {}} + isVerticalLegend={false} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().disabled).toBeTruthy(); + }); + + it('reflects current setting in select', () => { + const CURRENT_SIZE = LegendSize.SMALL; + + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe(CURRENT_SIZE); + }); + + it('allows user to select a new option', () => { + const onSizeChange = jest.fn(); + + const instance = shallow( + + ); + + const onChange = instance.find(EuiSuperSelect).props().onChange; + + onChange(LegendSize.EXTRA_LARGE); + onChange(DEFAULT_LEGEND_SIZE); + + expect(onSizeChange).toHaveBeenNthCalledWith(1, LegendSize.EXTRA_LARGE); + expect(onSizeChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('hides "auto" option if visualization not using it', () => { + const getOptions = (showAutoOption: boolean) => + shallow( + {}} + isVerticalLegend={true} + showAutoOption={showAutoOption} + /> + ) + .find(EuiSuperSelect) + .props().options; + + const autoOption = expect.objectContaining({ value: LegendSize.AUTO }); + + expect(getOptions(true)).toContainEqual(autoOption); + expect(getOptions(false)).not.toContainEqual(autoOption); + }); +}); diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx index 768db7d3dd78e..bbe47295c99e6 100644 --- a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx @@ -10,27 +10,11 @@ import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFormRow, EuiSuperSelect, EuiToolTip } from '@elastic/eui'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; -enum LegendSizes { - AUTO = '0', - SMALL = '80', - MEDIUM = '130', - LARGE = '180', - EXTRA_LARGE = '230', -} - -const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ +const legendSizeOptions: Array<{ value: LegendSize; inputDisplay: string }> = [ { - value: LegendSizes.AUTO, - inputDisplay: i18n.translate( - 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', - { - defaultMessage: 'Auto', - } - ), - }, - { - value: LegendSizes.SMALL, + value: LegendSize.SMALL, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.small', { @@ -39,7 +23,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.MEDIUM, + value: LegendSize.MEDIUM, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.medium', { @@ -48,7 +32,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.LARGE, + value: LegendSize.LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.large', { @@ -57,7 +41,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.EXTRA_LARGE, + value: LegendSize.EXTRA_LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.extraLarge', { @@ -68,15 +52,17 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ]; interface LegendSizeSettingsProps { - legendSize?: number; - onLegendSizeChange: (size?: number) => void; + legendSize?: LegendSize; + onLegendSizeChange: (size?: LegendSize) => void; isVerticalLegend: boolean; + showAutoOption: boolean; } export const LegendSizeSettings = ({ legendSize, onLegendSizeChange, isVerticalLegend, + showAutoOption, }: LegendSizeSettingsProps) => { useEffect(() => { if (legendSize && !isVerticalLegend) { @@ -85,16 +71,31 @@ export const LegendSizeSettings = ({ }, [isVerticalLegend, legendSize, onLegendSizeChange]); const onLegendSizeOptionChange = useCallback( - (option) => onLegendSizeChange(Number(option) || undefined), + (option) => onLegendSizeChange(option === DEFAULT_LEGEND_SIZE ? undefined : option), [onLegendSizeChange] ); + const options = showAutoOption + ? [ + { + value: LegendSize.AUTO, + inputDisplay: i18n.translate( + 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', + { + defaultMessage: 'Auto', + } + ), + }, + ...legendSizeOptions, + ] + : legendSizeOptions; + const legendSizeSelect = ( diff --git a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx index f592ee3933c1c..3c06e65e2cff4 100644 --- a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx @@ -26,7 +26,7 @@ import { LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; import { colorSchemas } from '@kbn/charts-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { HeatmapVisParams, HeatmapTypeProps, ValueAxis } from '../../types'; import { LabelsPanel } from './labels_panel'; import { legendPositions, scaleTypes } from '../collections'; @@ -42,6 +42,9 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { const isColorsNumberInvalid = stateParams.colorsNumber < 2 || stateParams.colorsNumber > 10; const [isColorRangesValid, setIsColorRangesValid] = useState(false); + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const setValueAxisScale = useCallback( (paramName: T, value: ValueAxis['scale'][T]) => setValue('valueAxes', [ @@ -91,12 +94,13 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/heatmap/public/types.ts b/src/plugins/vis_types/heatmap/public/types.ts index 8301d246e9f63..9d41a132f00b1 100644 --- a/src/plugins/vis_types/heatmap/public/types.ts +++ b/src/plugins/vis_types/heatmap/public/types.ts @@ -9,6 +9,7 @@ import { UiCounterMetricType } from '@kbn/analytics'; import type { Position } from '@elastic/charts'; import type { ChartsPluginSetup, Style, Labels, ColorSchemas } from '@kbn/charts-plugin/public'; import { Range } from '@kbn/expressions-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; export interface HeatmapTypeProps { showElasticChartsOptions?: boolean; @@ -23,7 +24,7 @@ export interface HeatmapVisParams { legendPosition: Position; truncateLegend?: boolean; maxLegendLines?: number; - legendSize?: number; + legendSize?: LegendSize; lastRangeIsRightOpen: boolean; percentageMode: boolean; valueAxes: ValueAxis[]; diff --git a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap index 904dff6ee1192..5b8bd613609f9 100644 --- a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap @@ -93,6 +93,9 @@ Object { "legendPosition": Array [ "right", ], + "legendSize": Array [ + "large", + ], "metric": Array [ Object { "chain": Array [ diff --git a/src/plugins/vis_types/pie/public/editor/components/pie.tsx b/src/plugins/vis_types/pie/public/editor/components/pie.tsx index f1f335f186ffd..cd1e565861d78 100644 --- a/src/plugins/vis_types/pie/public/editor/components/pie.tsx +++ b/src/plugins/vis_types/pie/public/editor/components/pie.tsx @@ -31,7 +31,7 @@ import { LongLegendOptions, LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { PartitionVisParams, LabelPositions, @@ -97,6 +97,9 @@ const PieOptions = (props: PieOptionsProps) => { const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const getLegendDisplay = useCallback( (isVisible: boolean) => (isVisible ? LegendDisplay.SHOW : LegendDisplay.HIDE), [] @@ -234,12 +237,13 @@ const PieOptions = (props: PieOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index f8836f208d916..4c638689ca310 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -7,6 +7,7 @@ */ import { LegendDisplay } from '@kbn/expression-partition-vis-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; export const samplePieVis = { type: { @@ -142,6 +143,7 @@ export const samplePieVis = { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.LARGE, isDonut: true, labels: { show: true, diff --git a/src/plugins/vis_types/pie/public/to_ast.ts b/src/plugins/vis_types/pie/public/to_ast.ts index aaac3040d7bd3..7a131dbb76b9c 100644 --- a/src/plugins/vis_types/pie/public/to_ast.ts +++ b/src/plugins/vis_types/pie/public/to_ast.ts @@ -62,14 +62,14 @@ export const toExpressionAst: VisToExpressionAst = async (vi addTooltip: vis.params.addTooltip, legendDisplay: vis.params.legendDisplay, legendPosition: vis.params.legendPosition, - nestedLegend: vis.params?.nestedLegend ?? false, + nestedLegend: vis.params.nestedLegend ?? false, truncateLegend: vis.params.truncateLegend, maxLegendLines: vis.params.maxLegendLines, legendSize: vis.params.legendSize, - distinctColors: vis.params?.distinctColors, + distinctColors: vis.params.distinctColors, isDonut: vis.params.isDonut ?? false, emptySizeRatio: vis.params.emptySizeRatio, - palette: preparePalette(vis.params?.palette), + palette: preparePalette(vis.params.palette), labels: prepareLabels(vis.params.labels), metric: schemas.metric.map(prepareDimension), buckets: schemas.segment?.map(prepareDimension), diff --git a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx index 9aa30f95f1809..25bd6b0b9031c 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx @@ -35,7 +35,7 @@ export const createGridColumns = ( ) => { const onFilterClick = (data: FilterCellData, negate: boolean) => { fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { diff --git a/src/plugins/vis_types/table/public/table_vis_fn.test.ts b/src/plugins/vis_types/table/public/table_vis_fn.test.ts index 98336d6cc67d4..87da839578117 100644 --- a/src/plugins/vis_types/table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_types/table/public/table_vis_fn.test.ts @@ -79,6 +79,7 @@ describe('interpreter/functions#table', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx index e29d47844950e..181ea661b69f8 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx @@ -125,7 +125,7 @@ function TimeseriesVisualization({ const data = getClickFilterData(points, tables, model); const event = { - name: 'filterBucket', + name: 'filter', data: { data, negate: false, diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap index 233940d97d38a..6d20088dbff32 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap @@ -8,7 +8,7 @@ Object { "area", ], "visConfig": Array [ - "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"legendSize\\":\\"small\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap index 1eedae99ffedb..80e52d95be5c9 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap @@ -5,7 +5,7 @@ Object { "addArgument": [Function], "arguments": Object { "visConfig": Array [ - "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"legendSize\\":\\"large\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx index 7f948917764df..5c4c4e3c2c145 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx @@ -201,7 +201,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: false }, }); expect(fireEvent).toHaveBeenCalledTimes(1); @@ -216,7 +216,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: true }, }); expect(fireEvent).toHaveBeenCalledTimes(1); diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx index 577a76dd84454..fedeb03cdde28 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx @@ -87,7 +87,7 @@ export class VisLegend extends PureComponent { filter = ({ values: data }: LegendItem, negate: boolean) => { this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data, negate, diff --git a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js index fe8388e025b94..177febfb2812c 100644 --- a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js +++ b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js @@ -95,7 +95,7 @@ export class Handler { }); case 'click': return self.vis.emit(eventType, { - name: 'filterBucket', + name: 'filter', data: eventPayload, }); } diff --git a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap index 7ee1b0d2b2053..048b07dbf34ed 100644 --- a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap @@ -32,6 +32,9 @@ Object { "legendPosition": Array [ "top", ], + "legendSize": Array [ + "small", + ], "maxLegendLines": Array [ 1, ], diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx index 15b5adf00b41f..c12eae1b20b8e 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; @@ -20,6 +20,7 @@ import { } from '@kbn/vis-default-editor-plugin/public'; import { BUCKET_TYPES } from '@kbn/data-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { VisParams } from '../../../../types'; import { GridPanel } from './grid_panel'; import { ThresholdPanel } from './threshold_panel'; @@ -41,6 +42,10 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) [stateParams.seriesParams, aggs.aggs] ); + const legendSize = stateParams.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const handleLegendSizeChange = useCallback((size) => setValue('legendSize', size), [setValue]); return ( @@ -64,12 +69,13 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) setValue={setValue} /> {vis.data.aggs!.aggs.some( diff --git a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts index 96c4ab112caf1..08319e8e9a11b 100644 --- a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts +++ b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts @@ -13,7 +13,12 @@ import type { Datatable, Render, } from '@kbn/expressions-plugin/common'; -import { prepareLogTable, Dimension } from '@kbn/visualizations-plugin/public'; +import { + prepareLogTable, + Dimension, + DEFAULT_LEGEND_SIZE, + LegendSize, +} from '@kbn/visualizations-plugin/public'; import type { ChartType } from '../../common'; import type { VisParams, XYVisConfig } from '../types'; @@ -73,10 +78,19 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({ }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('visTypeXy.function.args.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, addLegend: { types: ['boolean'], diff --git a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts index 436a284b1657a..3c1d87d2efc3c 100644 --- a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts @@ -5,6 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { LegendSize } from '@kbn/visualizations-plugin/common'; + export const sampleAreaVis = { type: { name: 'area', @@ -282,6 +285,7 @@ export const sampleAreaVis = { addTooltip: true, addLegend: true, legendPosition: 'top', + legendSize: LegendSize.SMALL, times: [], addTimeMarker: false, truncateLegend: true, diff --git a/src/plugins/vis_types/xy/public/types/param.ts b/src/plugins/vis_types/xy/public/types/param.ts index 708eb1cbdd196..a491efad97fcb 100644 --- a/src/plugins/vis_types/xy/public/types/param.ts +++ b/src/plugins/vis_types/xy/public/types/param.ts @@ -15,6 +15,7 @@ import type { FakeParams, HistogramParams, DateHistogramParams, + LegendSize, } from '@kbn/visualizations-plugin/public'; import type { ChartType, XyVisType } from '../../common'; import type { @@ -124,7 +125,7 @@ export interface VisParams { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; categoryAxes: CategoryAxis[]; orderBucketsBySum?: boolean; labels: Labels; @@ -165,7 +166,7 @@ export interface XYVisConfig { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; orderBucketsBySum?: boolean; labels: ExpressionValueLabel; thresholdLine: ExpressionValueThresholdLine; diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index 7c0636ab284fb..a744841601a67 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -33,7 +33,11 @@ import { useActiveCursor, } from '@kbn/charts-plugin/public'; import { Datatable, IInterpreterRenderHandlers } from '@kbn/expressions-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, + PersistedState, +} from '@kbn/visualizations-plugin/public'; import { VisParams } from './types'; import { getAdjustedDomain, @@ -361,7 +365,7 @@ const VisComponent = (props: VisComponentProps) => { tooltip: { visible: syncTooltips, placement: Placement.Right }, }} legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} xDomain={xDomain} adjustedXDomain={adjustedXDomain} legendColorPicker={legendColorPicker} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index 0b840c8ff13fc..ea695e6bdca02 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -26,3 +26,21 @@ export const VisualizeConstants = { EDIT_BY_VALUE_PATH: '/edit_by_value', APP_ID: 'visualize', }; + +export enum LegendSize { + AUTO = 'auto', + SMALL = 'small', + MEDIUM = 'medium', + LARGE = 'large', + EXTRA_LARGE = 'xlarge', +} + +export const LegendSizeToPixels = { + [LegendSize.AUTO]: undefined, + [LegendSize.SMALL]: 80, + [LegendSize.MEDIUM]: 130, + [LegendSize.LARGE]: 180, + [LegendSize.EXTRA_LARGE]: 230, +} as const; + +export const DEFAULT_LEGEND_SIZE = LegendSize.MEDIUM; diff --git a/src/plugins/visualizations/common/index.ts b/src/plugins/visualizations/common/index.ts index d784fcfd09eb9..1dd9a0e90477c 100644 --- a/src/plugins/visualizations/common/index.ts +++ b/src/plugins/visualizations/common/index.ts @@ -13,3 +13,4 @@ export * from './types'; export * from './utils'; export * from './expression_functions'; +export { LegendSize, LegendSizeToPixels, DEFAULT_LEGEND_SIZE } from './constants'; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 22217e9de9abe..67b13c8236708 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -56,6 +56,9 @@ export { VISUALIZE_ENABLE_LABS_SETTING, SAVED_OBJECTS_LIMIT_SETTING, SAVED_OBJECTS_PER_PAGE_SETTING, + LegendSize, + LegendSizeToPixels, + DEFAULT_LEGEND_SIZE, } from '../common/constants'; export type { SavedVisState, VisParams, Dimension } from '../common'; export { prepareLogTable } from '../common'; diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts index d92810743bed4..1d8a00ab2e33b 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts @@ -26,6 +26,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from '../migrations/visualization_common_migrations'; import { SerializedVis } from '../../common'; @@ -97,6 +98,11 @@ const byValueUpdatePieVisApi = (state: SerializableRecord) => ({ savedVis: commonUpdatePieVisApi(state.savedVis), }); +const byValuePreserveOldLegendSizeDefault = (state: SerializableRecord) => ({ + ...state, + savedVis: commonPreserveOldLegendSizeDefault(state.savedVis), +}); + const getEmbeddedVisualizationSearchSourceMigrations = ( searchSourceMigrations: MigrateFunctionsObject ) => @@ -144,6 +150,7 @@ export const makeVisualizeEmbeddableFactory = '7.17.0': (state) => flow(byValueAddDropLastBucketIntoTSVBModel714Above)(state), '8.0.0': (state) => flow(byValueRemoveMarkdownLessFromTSVB)(state), '8.1.0': (state) => flow(byValueUpdatePieVisApi)(state), + '8.3.0': (state) => flow(byValuePreserveOldLegendSizeDefault)(state), } ), }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index aec452e356abe..57d8142822882 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -215,3 +215,34 @@ export const commonUpdatePieVisApi = (visState: any) => { return visState; }; + +export const commonPreserveOldLegendSizeDefault = (visState: any) => { + const visualizationTypesWithLegends = [ + 'pie', + 'area', + 'histogram', + 'horizontal_bar', + 'line', + 'heatmap', + ]; + + const pixelsToLegendSize: Record = { + undefined: 'auto', + '80': 'small', + '130': 'medium', + '180': 'large', + '230': 'xlarge', + }; + + if (visualizationTypesWithLegends.includes(visState?.type)) { + return { + ...visState, + params: { + ...visState.params, + legendSize: pixelsToLegendSize[visState.params?.legendSize], + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 19f117ec18cc8..626dc14e05396 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2564,4 +2564,63 @@ describe('migration visualization', () => { expect(otherParams.addLegend).toBeUndefined(); }); }); + + describe('8.3.0 - preserves default legend size for existing visualizations', () => { + const getDoc = (type: string, legendSize: number | undefined) => ({ + attributes: { + title: 'Some Vis with a Legend', + description: '', + visState: JSON.stringify({ + type, + title: 'Pie vis', + params: { + legendSize, + }, + }), + }, + }); + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['8.3.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const autoLegendSize = 'auto'; + const largeLegendSize = 'large'; + const largeLegendSizePx = 180; + + test.each([ + ['pie', undefined, autoLegendSize], + ['area', undefined, autoLegendSize], + ['histogram', undefined, autoLegendSize], + ['horizontal_bar', undefined, autoLegendSize], + ['line', undefined, autoLegendSize], + ['heatmap', undefined, autoLegendSize], + ['pie', largeLegendSizePx, largeLegendSize], + ['area', largeLegendSizePx, largeLegendSize], + ['histogram', largeLegendSizePx, largeLegendSize], + ['horizontal_bar', largeLegendSizePx, largeLegendSize], + ['line', largeLegendSizePx, largeLegendSize], + ['heatmap', largeLegendSizePx, largeLegendSize], + ])( + 'given a %s visualization with current legend size of %s -- sets legend size to %s', + ( + visualizationType: string, + currentLegendSize: number | undefined, + expectedLegendSize: string + ) => { + const visState = JSON.parse( + migrate(getDoc(visualizationType, currentLegendSize)).attributes.visState + ); + + expect(visState.params.legendSize).toBe(expectedLegendSize); + } + ); + + test.each(['metric', 'gauge', 'table'])('leaves visualization without legend alone: %s', () => { + const visState = JSON.parse(migrate(getDoc('table', undefined)).attributes.visState); + + expect(visState.params.legendSize).toBeUndefined(); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index d236ad83c853a..bb2d68cfd35d9 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -28,6 +28,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from './visualization_common_migrations'; import { VisualizationSavedObjectAttributes } from '../../common'; @@ -1158,6 +1159,30 @@ export const updatePieVisApi: SavedObjectMigrationFn = (doc) => { return doc; }; +const preserveOldLegendSizeDefault: 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 + } + + const newVisState = commonPreserveOldLegendSizeDefault(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + + return doc; +}; + const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1214,6 +1239,7 @@ const visualizationSavedObjectTypeMigrations = { '7.17.0': flow(addDropLastBucketIntoTSVBModel714Above), '8.0.0': flow(removeMarkdownLessFromTSVB), '8.1.0': flow(updatePieVisApi), + '8.3.0': preserveOldLegendSizeDefault, }; /** diff --git a/src/setup_node_env/ensure_node_preserve_symlinks.js b/src/setup_node_env/ensure_node_preserve_symlinks.js index 3899564203622..5ec286801bdc4 100644 --- a/src/setup_node_env/ensure_node_preserve_symlinks.js +++ b/src/setup_node_env/ensure_node_preserve_symlinks.js @@ -89,10 +89,18 @@ } if (spawnResult.signal !== null) { - return 128 + spawnResult.signal; + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with signal', + spawnResult.signal + ); + return 1; } if (spawnResult.error) { + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with error', + spawnResult.error + ); return 1; } diff --git a/test/analytics/tests/instrumented_events/from_the_browser/click.ts b/test/analytics/tests/instrumented_events/from_the_browser/click.ts new file mode 100644 index 0000000000000..7b9816ba13e4e --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/click.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + + describe('General "click"', () => { + beforeEach(async () => { + await common.navigateToApp('home'); + // Just click on the top div and expect it's still there... we're just testing the click event generation + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + }); + + it('should emit a "click" event', async () => { + const [event] = await ebtUIHelper.getLastEvents(1, ['click']); + expect(event.event_type).to.eql('click'); + expect(event.properties.target).to.be.an('array'); + const targets = event.properties.target as string[]; + expect(targets.includes('DIV')).to.be(true); + expect(targets.includes('id=kibana-body')).to.be(true); + expect(targets.includes('data-test-subj=kibanaChrome')).to.be(true); + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts index 58d8de723639d..b6f691f419dab 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -72,7 +72,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(event.context).not.to.have.property('page'); // In the Home app it's not available. }); - it('should have the properties provided by the "license info" context provider', () => { + it('should have the properties provided by the "license info" context provider', async () => { + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + [event] = await ebtUIHelper.getLastEvents(1, ['click']); // Get a later event to ensure license has been obtained already. expect(event.context).to.have.property('license_id'); expect(event.context.license_id).to.be.a('string'); expect(event.context).to.have.property('license_status'); diff --git a/test/analytics/tests/instrumented_events/from_the_browser/index.ts b/test/analytics/tests/instrumented_events/from_the_browser/index.ts index 69aff97006d72..2fe99373d5214 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../../services'; export default function ({ loadTestFile }: FtrProviderContext) { describe('from the browser', () => { // Add tests for UI-instrumented events here: + loadTestFile(require.resolve('./click')); loadTestFile(require.resolve('./loaded_kibana')); loadTestFile(require.resolve('./core_context_providers')); }); diff --git a/test/common/fixtures/plugins/newsfeed/server/plugin.ts b/test/common/fixtures/plugins/newsfeed/server/plugin.ts index 85dadcfa8d7d2..5eb27325e535c 100644 --- a/test/common/fixtures/plugins/newsfeed/server/plugin.ts +++ b/test/common/fixtures/plugins/newsfeed/server/plugin.ts @@ -59,7 +59,7 @@ export class NewsFeedSimulatorPlugin implements Plugin { title: { en: 'Staging too!' }, description: { en: 'Hello world' }, link_text: { en: 'Generic feed-viewer could go here' }, - link_url: { en: 'https://feeds-staging.elastic.co' }, + link_url: { en: 'https://feeds.elastic.co' }, languages: null, badge: null, image_url: null, @@ -71,7 +71,7 @@ export class NewsFeedSimulatorPlugin implements Plugin { title: { en: 'This item is expired!' }, description: { en: 'This should not show up.' }, link_text: { en: 'Generic feed-viewer could go here' }, - link_url: { en: 'https://feeds-staging.elastic.co' }, + link_url: { en: 'https://feeds.elastic.co' }, languages: null, badge: null, image_url: null, diff --git a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts index dc1a74ea74b7d..56dcfe2388bc2 100644 --- a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts +++ b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts @@ -84,7 +84,7 @@ export default function ({ ); await PageObjects.dashboard.clickExitFullScreenLogoButton(); - expect(percentDifference).to.be.lessThan(0.022); + expect(percentDifference).to.be.lessThan(0.029); }); }); } diff --git a/test/functional/apps/visualize/group2/_metric_chart.ts b/test/functional/apps/visualize/group2/_metric_chart.ts index b797ccb630363..d28835ea556e3 100644 --- a/test/functional/apps/visualize/group2/_metric_chart.ts +++ b/test/functional/apps/visualize/group2/_metric_chart.ts @@ -171,14 +171,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('with filters', function () { - it('should prevent filtering without buckets', async function () { + it('should allow filtering without buckets', async function () { let filterCount = 0; await retry.try(async function tryingForTime() { // click first metric bucket await PageObjects.visEditor.clickMetricByIndex(0); filterCount = await filterBar.getFilterCount(); }); - expect(filterCount).to.equal(0); + await filterBar.removeAllFilters(); + expect(filterCount).to.equal(1); }); it('should allow filtering with buckets', async function () { diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index 851f53499e94f..7bbaa256f0360 100644 Binary files a/test/functional/screenshots/baseline/area_chart.png and b/test/functional/screenshots/baseline/area_chart.png differ diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 2d87c0575845f..d295be040db7a 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -139,7 +139,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'newsfeed.fetchInterval (duration)', 'newsfeed.mainInterval (duration)', 'newsfeed.service.pathTemplate (string)', - 'newsfeed.service.urlRoot (any)', + 'newsfeed.service.urlRoot (string)', 'telemetry.allowChangingOptInStatus (boolean)', 'telemetry.banner (boolean)', 'telemetry.enabled (boolean)', diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts new file mode 100644 index 0000000000000..bcbb72518e4e4 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { AlertingEventLogger } from './alerting_event_logger'; + +const createAlertingEventLoggerMock = () => { + const mock: jest.Mocked> = { + initialize: jest.fn(), + start: jest.fn(), + getEvent: jest.fn(), + getStartAndDuration: jest.fn(), + setRuleName: jest.fn(), + setExecutionSucceeded: jest.fn(), + setExecutionFailed: jest.fn(), + logTimeout: jest.fn(), + logAlert: jest.fn(), + logAction: jest.fn(), + done: jest.fn(), + }; + return mock; +}; + +export const alertingEventLoggerMock = { + create: createAlertingEventLoggerMock, +}; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts new file mode 100644 index 0000000000000..c980d61bb08fe --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -0,0 +1,1115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { + AlertingEventLogger, + RuleContextOpts, + initializeExecuteRecord, + createExecuteStartRecord, + createExecuteTimeoutRecord, + createAlertRecord, + createActionExecuteRecord, + updateEvent, +} from './alerting_event_logger'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { + ActionsCompletion, + RecoveredActionGroup, + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '../../types'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; + +const mockNow = '2020-01-01T02:00:00.000Z'; +const eventLogger = eventLoggerMock.create(); + +const ruleType: jest.Mocked = { + id: 'test', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + ruleTaskTimeout: '1m', +}; + +const context: RuleContextOpts = { + ruleId: '123', + ruleType, + consumer: 'test-consumer', + spaceId: 'test-space', + executionId: 'abcd-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), +}; + +const contextWithScheduleDelay = { ...context, taskScheduleDelay: 7200000 }; +const contextWithName = { ...contextWithScheduleDelay, ruleName: 'my-super-cool-rule' }; + +const alert = { + action: EVENT_LOG_ACTIONS.activeInstance, + id: 'aaabbb', + message: `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'`, + group: 'aGroup', + subgroup: 'bSubgroup', + state: { + start: '2020-01-01T02:00:00.000Z', + end: '2020-01-01T03:00:00.000Z', + duration: '2343252346', + }, +}; + +const action = { + id: 'abc', + typeId: '.email', + alertId: '123', + alertGroup: 'aGroup', + alertSubgroup: 'bSubgroup', +}; + +describe('AlertingEventLogger', () => { + let alertingEventLogger: AlertingEventLogger; + + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(mockNow)); + }); + + beforeEach(() => { + jest.resetAllMocks(); + alertingEventLogger = new AlertingEventLogger(eventLogger); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('initialize()', () => { + test('initialization should succeed if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.initialize(context)).not.toThrow(); + }); + + test('initialization should fail if alertingEventLogger has already been initialized', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.initialize(context)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger already initialized"` + ); + }); + }); + + describe('start()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should call eventLogger "startTiming" and "logEvent"', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + + expect(eventLogger.startTiming).toHaveBeenCalledWith( + initializeExecuteRecord(contextWithScheduleDelay), + new Date(mockNow) + ); + expect(eventLogger.logEvent).toHaveBeenCalledWith( + createExecuteStartRecord(contextWithScheduleDelay, new Date(mockNow)) + ); + }); + + test('should initialize the "execute" event', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + }, + }); + }); + }); + + describe('setRuleName()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should update event with rule name correctly', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + }); + }); + }); + + describe('setExecutionSucceeded()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + alertingEventLogger.setExecutionSucceeded('success!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'success', + }, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + message: 'success!', + }); + }); + }); + + describe('setExecutionFailed()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'failure', + }, + error: { + message: 'something went wrong!', + }, + message: 'rule failed!', + }); + }); + }); + + describe('logTimeout()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logTimeout(); + + const event = createExecuteTimeoutRecord(contextWithName); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAlert()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAlert(alert); + + const event = createAlertRecord(contextWithName, alert); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAction()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAction(action); + + const event = createActionExecuteRecord(contextWithName, action); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('done()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log event if no status or metrics are provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({}); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + + test('should set fields from execution status if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), status: 'active' }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event?.kibana, + alerting: { + status: 'active', + }, + }, + }; + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: RuleExecutionStatusErrorReasons.Execute, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: RuleExecutionStatusErrorReasons.Execute, + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and does not overwrite existing error message', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionFailed( + 'i am an existing error message', + 'i am an existing error message!' + ); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'i am an existing error message!', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'i am an existing error message', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: undefined as unknown as RuleExecutionStatusWarningReasons, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'unknown', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses existing message if no message is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: undefined as unknown as string, + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionSucceeded('success!'); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'maxExecutableActions', + outcome: 'success', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'success!', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution metrics if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: { + numberOfTriggeredActions: 1, + numberOfGeneratedActions: 2, + numberOfActiveAlerts: 3, + numberOfNewAlerts: 4, + numberOfRecoveredAlerts: 5, + numSearches: 6, + esSearchDurationMs: 3300, + totalSearchDurationMs: 10333, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 1, + number_of_generated_actions: 2, + number_of_active_alerts: 3, + number_of_new_alerts: 4, + number_of_recovered_alerts: 5, + total_number_of_alerts: 8, + number_of_searches: 6, + es_search_duration_ms: 3300, + total_search_duration_ms: 10333, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields to 0 execution metrics are provided but undefined', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: {} as unknown as RuleRunMetrics, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 0, + number_of_generated_actions: 0, + number_of_active_alerts: 0, + number_of_new_alerts: 0, + number_of_recovered_alerts: 0, + total_number_of_alerts: 0, + number_of_searches: 0, + es_search_duration_ms: 0, + total_search_duration_ms: 0, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + }); +}); + +describe('createExecuteStartRecord', () => { + test('should create execute-start record', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord(contextWithScheduleDelay); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + }, + message: `rule execution start: "123"`, + }); + }); + + test('should create execute-start record with given start time', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord( + contextWithScheduleDelay, + new Date('2022-01-01T02:00:00.000Z') + ); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + start: '2022-01-01T02:00:00.000Z', + }, + message: `rule execution start: "123"`, + }); + }); +}); + +describe('initializeExecuteRecord', () => { + test('should populate initial set of fields in event log record', () => { + const record = initializeExecuteRecord(contextWithScheduleDelay); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.kibana?.task).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithScheduleDelay.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithScheduleDelay.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + contextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithScheduleDelay.ruleId, + type: 'alert', + type_id: contextWithScheduleDelay.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithScheduleDelay.spaceId]); + expect(record.kibana?.task?.scheduled).toEqual( + contextWithScheduleDelay.taskScheduledAt.toISOString() + ); + expect(record.kibana?.task?.schedule_delay).toEqual( + contextWithScheduleDelay.taskScheduleDelay * 1000000 + ); + expect(record?.rule?.id).toEqual(contextWithScheduleDelay.ruleId); + expect(record?.rule?.license).toEqual(contextWithScheduleDelay.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithScheduleDelay.ruleType.producer); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.rule?.name).toBeUndefined(); + expect(record?.message).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createExecuteTimeoutRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createExecuteTimeoutRecord(contextWithName); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-timeout'); + expect(record.event?.kind).toEqual('alert'); + expect(record.message).toEqual( + `rule: test:123: 'my-super-cool-rule' execution cancelled due to timeout - exceeded rule type timeout of 1m` + ); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createAlertRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createAlertRecord(contextWithName, alert); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('active-instance'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.event?.start).toEqual(alert.state.start); + expect(record.event?.end).toEqual(alert.state.end); + expect(record.event?.duration).toEqual(alert.state.duration); + expect(record.message).toEqual( + `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(alert.id); + expect(record.kibana?.alerting?.action_group_id).toEqual(alert.group); + expect(record.kibana?.alerting?.action_subgroup).toEqual(alert.subgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createActionExecuteRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createActionExecuteRecord(contextWithName, action); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-action'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.message).toEqual( + `alert: test:123: 'my-super-cool-rule' instanceId: '123' scheduled actionGroup(subgroup): 'aGroup(bSubgroup)' action: .email:abc` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(action.alertId); + expect(record.kibana?.alerting?.action_group_id).toEqual(action.alertGroup); + expect(record.kibana?.alerting?.action_subgroup).toEqual(action.alertSubgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + { + id: action.id, + type: 'action', + type_id: action.typeId, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('updateEvent', () => { + let event: IEvent; + let expectedEvent: IEvent; + beforeEach(() => { + event = initializeExecuteRecord(contextWithScheduleDelay); + expectedEvent = initializeExecuteRecord(contextWithScheduleDelay); + }); + + test('throws error if event is null', () => { + expect(() => updateEvent(null as unknown as IEvent, {})).toThrowErrorMatchingInlineSnapshot( + `"Cannot update event because it is not initialized."` + ); + }); + + test('throws error if event is undefined', () => { + expect(() => + updateEvent(undefined as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); + }); + + test('updates event message if provided', () => { + updateEvent(event, { message: 'tell me something good' }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + }); + }); + + test('updates event outcome if provided', () => { + updateEvent(event, { outcome: 'yay' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + outcome: 'yay', + }, + }); + }); + + test('updates event error if provided', () => { + updateEvent(event, { error: 'oh no' }); + expect(event).toEqual({ + ...expectedEvent, + error: { + message: 'oh no', + }, + }); + }); + + test('updates event rule name if provided', () => { + updateEvent(event, { ruleName: 'test rule' }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); + + test('updates event status if provided', () => { + updateEvent(event, { status: 'ok' }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + }); + }); + + test('updates event reason if provided', () => { + updateEvent(event, { reason: 'my-reason' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + reason: 'my-reason', + }, + }); + }); + + test('updates all fields if provided', () => { + updateEvent(event, { + message: 'tell me something good', + outcome: 'yay', + error: 'oh no', + ruleName: 'test rule', + status: 'ok', + reason: 'my-reason', + }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + event: { + ...expectedEvent?.event, + outcome: 'yay', + reason: 'my-reason', + }, + error: { + message: 'oh no', + }, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); +}); + +function mockEventLoggerStartTiming() { + eventLogger.startTiming.mockImplementationOnce((event: IEvent, startTime?: Date) => { + if (event == null) return; + event.event = event.event || {}; + + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); + }); +} diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts new file mode 100644 index 0000000000000..74a8a26f531f2 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -0,0 +1,389 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { AlertInstanceState, RuleExecutionStatus } from '../../types'; +import { createAlertEventLogRecordObject } from '../create_alert_event_log_record_object'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; + +// 1,000,000 nanoseconds in 1 millisecond +const Millis2Nanos = 1000 * 1000; + +export interface RuleContextOpts { + ruleId: string; + ruleType: UntypedNormalizedRuleType; + consumer: string; + namespace?: string; + spaceId: string; + executionId: string; + taskScheduledAt: Date; + ruleName?: string; +} + +type RuleContext = RuleContextOpts & { + taskScheduleDelay: number; +}; + +interface DoneOpts { + status?: RuleExecutionStatus; + metrics?: RuleRunMetrics | null; +} + +interface AlertOpts { + action: string; + id: string; + message: string; + group?: string; + subgroup?: string; + state?: AlertInstanceState; +} + +interface ActionOpts { + id: string; + typeId: string; + alertId: string; + alertGroup?: string; + alertSubgroup?: string; +} + +export class AlertingEventLogger { + private eventLogger: IEventLogger; + private isInitialized = false; + private startTime?: Date; + private ruleContext?: RuleContextOpts; + + // this is the "execute" event that will be updated over the lifecycle of this class + private event: IEvent; + + constructor(eventLogger: IEventLogger) { + this.eventLogger = eventLogger; + } + + // For testing purposes + public getEvent(): IEvent { + return this.event; + } + + public initialize(context: RuleContextOpts) { + if (this.isInitialized) { + throw new Error('AlertingEventLogger already initialized'); + } + this.isInitialized = true; + this.ruleContext = context; + } + + public start() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.startTime = new Date(); + + const context = { + ...this.ruleContext, + taskScheduleDelay: this.startTime.getTime() - this.ruleContext.taskScheduledAt.getTime(), + }; + + // Initialize the "execute" event + this.event = initializeExecuteRecord(context); + this.eventLogger.startTiming(this.event, this.startTime); + + // Create and log "execute-start" event + const executeStartEvent = createExecuteStartRecord(context, this.startTime); + this.eventLogger.logEvent(executeStartEvent); + } + + public getStartAndDuration(): { start?: Date; duration?: string | number } { + return { start: this.startTime, duration: this.event?.event?.duration }; + } + + public setRuleName(ruleName: string) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.ruleContext.ruleName = ruleName; + updateEvent(this.event, { ruleName }); + } + + public setExecutionSucceeded(message: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'success' }); + } + + public setExecutionFailed(message: string, errorMessage: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'failure', error: errorMessage }); + } + + public logTimeout() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createExecuteTimeoutRecord(this.ruleContext)); + } + + public logAlert(alert: AlertOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createAlertRecord(this.ruleContext, alert)); + } + + public logAction(action: ActionOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createActionExecuteRecord(this.ruleContext, action)); + } + + public done({ status, metrics }: DoneOpts) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.stopTiming(this.event); + + if (status) { + updateEvent(this.event, { status: status.status }); + + if (status.error) { + updateEvent(this.event, { + outcome: 'failure', + reason: status.error?.reason || 'unknown', + error: this.event?.error?.message || status.error.message, + ...(this.event.message + ? {} + : { + message: `${this.ruleContext.ruleType.id}:${this.ruleContext.ruleId}: execution failed`, + }), + }); + } else { + if (status.warning) { + updateEvent(this.event, { + reason: status.warning?.reason || 'unknown', + message: status.warning?.message || this.event?.message, + }); + } + } + } + + if (metrics) { + updateEvent(this.event, { metrics }); + } + + this.eventLogger.logEvent(this.event); + } +} + +export function createExecuteStartRecord(context: RuleContext, startTime?: Date) { + const event = initializeExecuteRecord(context); + return { + ...event, + event: { + ...event.event, + action: EVENT_LOG_ACTIONS.executeStart, + ...(startTime ? { start: startTime.toISOString() } : {}), + }, + message: `rule execution start: "${context.ruleId}"`, + }; +} + +export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: alert.action, + state: alert.state, + instanceId: alert.id, + group: alert.group, + subgroup: alert.subgroup, + message: alert.message, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createActionExecuteRecord(context: RuleContextOpts, action: ActionOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeAction, + instanceId: action.alertId, + group: action.alertGroup, + subgroup: action.alertSubgroup, + message: `alert: ${context.ruleType.id}:${context.ruleId}: '${context.ruleName}' instanceId: '${ + action.alertId + }' scheduled ${ + action.alertSubgroup + ? `actionGroup(subgroup): '${action.alertGroup}(${action.alertSubgroup})'` + : `actionGroup: '${action.alertGroup}'` + } action: ${action.typeId}:${action.id}`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + { + type: 'action', + id: action.id, + typeId: action.typeId, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createExecuteTimeoutRecord(context: RuleContextOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeTimeout, + message: `rule: ${context.ruleType.id}:${context.ruleId}: '${ + context.ruleName ?? '' + }' execution cancelled due to timeout - exceeded rule type timeout of ${ + context.ruleType.ruleTaskTimeout + }`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function initializeExecuteRecord(context: RuleContext) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.execute, + task: { + scheduled: context.taskScheduledAt.toISOString(), + scheduleDelay: Millis2Nanos * context.taskScheduleDelay, + }, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); +} + +interface UpdateEventOpts { + message?: string; + outcome?: string; + error?: string; + ruleName?: string; + status?: string; + reason?: string; + metrics?: RuleRunMetrics; +} +export function updateEvent(event: IEvent, opts: UpdateEventOpts) { + const { message, outcome, error, ruleName, status, reason, metrics } = opts; + if (!event) { + throw new Error('Cannot update event because it is not initialized.'); + } + if (message) { + event.message = message; + } + + if (outcome) { + event.event = event.event || {}; + event.event.outcome = outcome; + } + + if (error) { + event.error = event.error || {}; + event.error.message = error; + } + + if (ruleName) { + event.rule = { + ...event.rule, + name: ruleName, + }; + } + + if (status) { + event.kibana = event.kibana || {}; + event.kibana.alerting = event.kibana.alerting || {}; + event.kibana.alerting.status = status; + } + + if (reason) { + event.event = event.event || {}; + event.event.reason = reason; + } + + if (metrics) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.execution = event.kibana.alert.rule.execution || {}; + event.kibana.alert.rule.execution.metrics = { + number_of_triggered_actions: metrics.numberOfTriggeredActions + ? metrics.numberOfTriggeredActions + : 0, + number_of_generated_actions: metrics.numberOfGeneratedActions + ? metrics.numberOfGeneratedActions + : 0, + number_of_active_alerts: metrics.numberOfActiveAlerts ? metrics.numberOfActiveAlerts : 0, + number_of_new_alerts: metrics.numberOfNewAlerts ? metrics.numberOfNewAlerts : 0, + number_of_recovered_alerts: metrics.numberOfRecoveredAlerts + ? metrics.numberOfRecoveredAlerts + : 0, + total_number_of_alerts: + (metrics.numberOfActiveAlerts ?? 0) + (metrics.numberOfRecoveredAlerts ?? 0), + number_of_searches: metrics.numSearches ? metrics.numSearches : 0, + es_search_duration_ms: metrics.esSearchDurationMs ? metrics.esSearchDurationMs : 0, + total_search_duration_ms: metrics.totalSearchDurationMs ? metrics.totalSearchDurationMs : 0, + }; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 81f7fa7da02d2..b4ae05fd341b0 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -13,7 +13,6 @@ import { actionsMock, renderActionParameterTemplatesDefault, } from '@kbn/actions-plugin/server/mocks'; -import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { KibanaRequest } from '@kbn/core/server'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { InjectActionParamsOpts } from './inject_action_params'; @@ -26,11 +25,14 @@ import { RuleTypeState, } from '../types'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), })); +const alertingEventLogger = alertingEventLoggerMock.create(); + const ruleType: NormalizedRuleType< RuleTypeParams, RuleTypeParams, @@ -60,7 +62,6 @@ const ruleType: NormalizedRuleType< const actionsClient = actionsClientMock.create(); const mockActionsPlugin = actionsMock.createStart(); -const mockEventLogger = eventLoggerMock.create(); const createExecutionHandlerParams: jest.Mocked< CreateExecutionHandlerOptions< RuleTypeParams, @@ -83,7 +84,7 @@ const createExecutionHandlerParams: jest.Mocked< kibanaBaseUrl: 'http://localhost:5601', ruleType, logger: loggingSystemMock.create().get(), - eventLogger: mockEventLogger, + alertingEventLogger, actions: [ { id: '1', @@ -178,63 +179,13 @@ describe('Create Execution Handler', () => { ] `); - expect(mockEventLogger.logEvent).toHaveBeenCalledTimes(1); - expect(mockEventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "event": Object { - "action": "execute-action", - "category": Array [ - "alerts", - ], - "kind": "alert", - }, - "kibana": Object { - "alert": Object { - "rule": Object { - "consumer": "rule-consumer", - "execution": Object { - "uuid": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - }, - "rule_type_id": "test", - }, - }, - "alerting": Object { - "action_group_id": "default", - "instance_id": "2", - }, - "saved_objects": Array [ - Object { - "id": "1", - "namespace": "test1", - "rel": "primary", - "type": "alert", - "type_id": "test", - }, - Object { - "id": "1", - "namespace": "test1", - "type": "action", - "type_id": "test", - }, - ], - "space_ids": Array [ - "test1", - ], - }, - "message": "alert: test:1: 'name-of-alert' instanceId: '2' scheduled actionGroup: 'default' action: test:1", - "rule": Object { - "category": "test", - "id": "1", - "license": "basic", - "name": "name-of-alert", - "ruleset": "alerts", - }, - }, - ], - ] - `); + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { + id: '1', + typeId: 'test', + alertId: '2', + alertGroup: 'default', + }); expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ ruleId: '1', diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index ce212a3cbff1b..0383289ab91df 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -5,10 +5,8 @@ * 2.0. */ import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; import { transformActionParams } from './transform_action_params'; -import { EVENT_LOG_ACTIONS } from '../plugin'; import { injectActionParams } from './inject_action_params'; import { ActionsCompletion, @@ -17,9 +15,6 @@ import { RuleTypeParams, RuleTypeState, } from '../types'; - -import { UntypedNormalizedRuleType } from '../rule_type_registry'; -import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; export type ExecutionHandler = ( @@ -47,7 +42,7 @@ export function createExecutionHandler< apiKey, ruleType, kibanaBaseUrl, - eventLogger, + alertingEventLogger, request, ruleParams, supportsEphemeralTasks, @@ -117,8 +112,6 @@ export function createExecutionHandler< ruleRunMetricsStore.incrementNumberOfGeneratedActions(actions.length); - const ruleLabel = `${ruleType.id}:${ruleId}: '${ruleName}'`; - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; @@ -189,8 +182,6 @@ export function createExecutionHandler< ], }; - // TODO would be nice to add the action name here, but it's not available - const actionLabel = `${actionTypeId}:${action.id}`; if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { ephemeralActionsToSchedule--; try { @@ -204,39 +195,13 @@ export function createExecutionHandler< await actionsClient.enqueueExecution(enqueueOptions); } - const event = createAlertEventLogRecordObject({ - ruleId, - ruleType: ruleType as UntypedNormalizedRuleType, - consumer: ruleConsumer, - action: EVENT_LOG_ACTIONS.executeAction, - executionId, - spaceId, - instanceId: alertId, - group: actionGroup, - subgroup: actionSubgroup, - ruleName, - savedObjects: [ - { - type: 'alert', - id: ruleId, - typeId: ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - { - type: 'action', - id: action.id, - typeId: actionTypeId, - }, - ], - ...namespace, - message: `alert: ${ruleLabel} instanceId: '${alertId}' scheduled ${ - actionSubgroup - ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` - : `actionGroup: '${actionGroup}'` - } action: ${actionLabel}`, + alertingEventLogger.logAction({ + id: action.id, + typeId: actionTypeId, + alertId, + alertGroup: actionGroup, + alertSubgroup: actionSubgroup, }); - - eventLogger.logEvent(event); } }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 861f1a4bbec91..5e4594cda6c04 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -5,14 +5,8 @@ * 2.0. */ -import { isNil } from 'lodash'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { - Rule, - RuleExecutionStatusWarningReasons, - RuleTypeParams, - RecoveredActionGroup, -} from '../../common'; +import { Rule, RuleTypeParams, RecoveredActionGroup } from '../../common'; import { getDefaultRuleMonitoring } from './task_runner'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -108,6 +102,8 @@ export const ruleType: jest.Mocked = { recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', }; export const mockRunNowResponse = { @@ -182,178 +178,45 @@ export const mockTaskInstance = () => ({ ownerId: null, }); -export const generateAlertSO = (id: string) => ({ - id, - rel: 'primary', - type: 'alert', - type_id: RULE_TYPE_ID, -}); +export const generateAlertOpts = ({ action, group, subgroup, state, id }: GeneratorParams = {}) => { + id = id ?? '1'; + let message: string = ''; + switch (action) { + case EVENT_LOG_ACTIONS.newInstance: + message = `test:1: 'rule-name' created new alert: '${id}'`; + break; + case EVENT_LOG_ACTIONS.activeInstance: + message = subgroup + ? `test:1: 'rule-name' active alert: '${id}' in actionGroup(subgroup): 'default(${subgroup})'` + : `test:1: 'rule-name' active alert: '${id}' in actionGroup: 'default'`; + break; + case EVENT_LOG_ACTIONS.recoveredInstance: + message = `test:1: 'rule-name' alert '${id}' has recovered`; + break; + } + return { + action, + id, + message, + state, + ...(group ? { group } : {}), + ...(subgroup ? { subgroup } : {}), + }; +}; -export const generateActionSO = (id: string) => ({ +export const generateActionOpts = ({ + subgroup, id, - namespace: undefined, - type: 'action', - type_id: 'action', -}); - -export const generateEventLog = ({ - action, - task, - duration, - consumer, - start, - end, - outcome, - reason, - instanceId, - actionSubgroup, - actionGroupId, - actionId, - status, - numberOfTriggeredActions, - numberOfGeneratedActions, - numberOfActiveAlerts, - numberOfRecoveredAlerts, - numberOfNewAlerts, - savedObjects = [generateAlertSO('1')], + alertGroup, + alertId, }: GeneratorParams = {}) => ({ - ...(status === 'error' && { - error: { - message: generateErrorMessage(String(reason)), - }, - }), - event: { - action, - ...(!isNil(duration) && { duration }), - ...(start && { start }), - ...(end && { end }), - ...(outcome && { outcome }), - ...(reason && { reason }), - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - ...(consumer && { consumer }), - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - ...((!isNil(numberOfTriggeredActions) || !isNil(numberOfGeneratedActions)) && { - metrics: { - number_of_triggered_actions: numberOfTriggeredActions, - number_of_generated_actions: numberOfGeneratedActions, - number_of_active_alerts: numberOfActiveAlerts ?? 0, - number_of_new_alerts: numberOfNewAlerts ?? 0, - number_of_recovered_alerts: numberOfRecoveredAlerts ?? 0, - total_number_of_alerts: - ((numberOfActiveAlerts ?? 0) as number) + - ((numberOfRecoveredAlerts ?? 0) as number), - number_of_searches: 3, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - }), - }, - rule_type_id: 'test', - }, - }, - ...((actionSubgroup || actionGroupId || instanceId || status) && { - alerting: { - ...(actionSubgroup && { action_subgroup: actionSubgroup }), - ...(actionGroupId && { action_group_id: actionGroupId }), - ...(instanceId && { instance_id: instanceId }), - ...(status && { status }), - }, - }), - saved_objects: savedObjects, - space_ids: ['default'], - ...(task && { - task: { - schedule_delay: 0, - scheduled: DATE_1970, - }, - }), - }, - message: generateMessage({ - action, - instanceId, - actionGroupId, - actionSubgroup, - reason, - status, - actionId, - }), - rule: { - category: 'test', - id: '1', - license: 'basic', - ...(hasRuleName({ action, status }) && { name: RULE_NAME }), - ruleset: 'alerts', - }, + id: id ?? '1', + typeId: 'action', + alertId: alertId ?? '1', + alertGroup: alertGroup ?? 'default', + ...(subgroup ? { alertSubgroup: subgroup } : {}), }); -const generateMessage = ({ - action, - instanceId, - actionGroupId, - actionSubgroup, - actionId, - reason, - status, -}: GeneratorParams) => { - if (action === EVENT_LOG_ACTIONS.executeStart) { - return `rule execution start: "${mockTaskInstance().params.alertId}"`; - } - - if (action === EVENT_LOG_ACTIONS.newInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' created new alert: '${instanceId}'`; - } - - if (action === EVENT_LOG_ACTIONS.activeInstance) { - if (actionSubgroup) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup(subgroup): 'default(${actionSubgroup})'`; - } - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup: '${actionGroupId}'`; - } - - if (action === EVENT_LOG_ACTIONS.recoveredInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' alert '${instanceId}' has recovered`; - } - - if (action === EVENT_LOG_ACTIONS.executeAction) { - if (actionSubgroup) { - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${actionId}`; - } - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - - if (action === EVENT_LOG_ACTIONS.execute) { - if (status === 'error' && reason === 'execute') { - return `rule execution failure: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } - if (status === 'error') { - return `${RULE_TYPE_ID}:${RULE_ID}: execution failed`; - } - if (actionGroupId === 'recovered') { - return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - if ( - status === 'warning' && - reason === RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS - ) { - return `The maximum number of actions for this rule type was reached; excess actions were not triggered.`; - } - return `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } -}; - -const generateErrorMessage = (reason: string) => { - if (reason === 'disabled') { - return 'Rule failed to execute because rule ran after it was disabled.'; - } - return GENERIC_ERROR_MESSAGE; -}; - export const generateRunnerResult = ({ successRatio = 1, history = Array(false), @@ -424,6 +287,3 @@ export const generateAlertInstance = ({ id, duration, start }: GeneratorParams = }, }, }); -const hasRuleName = ({ action, status }: GeneratorParams) => { - return action !== 'execute-start' && status !== 'error'; -}; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 5318988e697c6..7d95f63f3c43c 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -45,9 +45,8 @@ import { ExecuteOptions } from '@kbn/actions-plugin/server/create_execute_functi import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; import moment from 'moment'; import { - generateActionSO, - generateAlertSO, - generateEventLog, + generateAlertOpts, + generateActionOpts, mockDate, mockedRuleTypeSavedObject, mockRunNowResponse, @@ -71,6 +70,11 @@ import { EVENT_LOG_ACTIONS } from '../plugin'; import { IN_MEMORY_METRICS } from '../monitoring'; import { translations } from '../constants/translations'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -80,17 +84,30 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); + let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, + }; }); afterAll(() => fakeTimer.restore()); @@ -186,6 +203,9 @@ describe('Task Runner', () => { ); mockedRuleTypeSavedObject.monitoring!.execution.history = []; mockedRuleTypeSavedObject.monitoring!.execution.calculated_metrics.success_ratio = 0; + + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('successfully executes the task', async () => { @@ -201,10 +221,13 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ state: true, history: [true] })); + expect(ruleType.executor).toHaveBeenCalledTimes(1); const call = ruleType.executor.mock.calls[0][0]; expect(call.params).toEqual({ bar: true }); @@ -247,16 +270,7 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledWith( - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -309,6 +323,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -331,66 +347,38 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionSubgroup: 'subDefault', - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - actionSubgroup: 'subDefault', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionSubgroup: 'subDefault', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - actionId: '1', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subDefault' }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -417,6 +405,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll: true, @@ -445,53 +435,29 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -536,6 +502,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll, @@ -588,6 +556,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -666,6 +636,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, throttle: '1d', @@ -709,6 +681,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -765,6 +739,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -773,46 +749,25 @@ describe('Task Runner', () => { await taskRunner.run(); expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + status: 'active', + logAlert: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: MOCK_DURATION, - start: DATE_1969, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1969, duration: MOCK_DURATION, bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test.each(ephemeralTestParams)( - 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert alert state has changed %s', + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert state has changed %s', async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true @@ -852,28 +807,34 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; await taskRunner.run(); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { bar: false }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } @@ -927,6 +888,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -934,21 +897,27 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + state: { bar: false }, + group: 'default', + subgroup: 'subgroup1', }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subgroup1' }) + ); expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -984,6 +953,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); await taskRunner.run(); @@ -1010,65 +981,33 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1126,6 +1065,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1153,76 +1094,40 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'recovered', - instanceId: '2', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 2, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2', alertId: '2', alertGroup: 'recovered' }) ); expect(enqueueFunction).toHaveBeenCalledTimes(2); @@ -1234,7 +1139,6 @@ describe('Task Runner', () => { test.each(ephemeralTestParams)( "should skip alertInstances which weren't active on the previous execution %s", async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { - const alertId = 'e558aaad-fd81-46d2-96fc-3bd8fc3dc03f'; customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true ); @@ -1270,13 +1174,12 @@ describe('Task Runner', () => { '2': { meta: {}, state: { bar: false } }, }, }, - params: { - alertId, - }, }, customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1284,24 +1187,32 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledWith( - `rule test:${alertId}: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `rule test:${alertId}: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` + `rule test:1: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` ); expect(logger.debug).nthCalledWith( 4, - `ruleRunStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + `ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` ); expect(logger.debug).nthCalledWith( 5, - `ruleRunMetrics for test:${alertId}: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` + `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('2'); expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('1'); @@ -1359,6 +1270,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, actions: [ @@ -1384,8 +1297,20 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult.state.alertInstances).toEqual(generateAlertInstance()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + ruleContext: { + ...alertingEventLoggerInitializer, + ruleType: ruleTypeWithCustomRecovery, + }, + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -1436,6 +1361,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1443,55 +1370,37 @@ describe('Task Runner', () => { generateAlertInstance({ id: 1, duration: MOCK_DURATION, start: DATE_1969 }) ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 0, + generatedActions: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - actionGroupId: 'default', - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + group: 'default', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1515,6 +1424,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1532,6 +1443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1560,6 +1473,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ ...SAVED_OBJECT, @@ -1590,6 +1505,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValueOnce(mockedRuleTypeSavedObject); rulesClient.get.mockResolvedValueOnce({ @@ -1626,6 +1542,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1633,28 +1550,13 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - reason: 'execute', - task: true, - status: 'error', - consumer: 'bar', - }) - ); + + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'execute', + executionStatus: 'failed', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1669,6 +1571,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); @@ -1676,28 +1579,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'decrypt', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'decrypt', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1712,6 +1600,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1720,28 +1609,12 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'license', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'license', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1756,6 +1629,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1764,20 +1638,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'unknown', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'unknown', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1792,6 +1659,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1799,20 +1667,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'read', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'read', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1831,6 +1692,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1868,6 +1730,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1897,6 +1760,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1927,6 +1791,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1950,6 +1815,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1975,6 +1841,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2022,6 +1889,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2030,76 +1899,53 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + newAlerts: 2, + status: 'active', + logAlert: 4, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ + id: '2', action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 3, - generateEventLog({ - duration: '0', - start: DATE_1970, - action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 4, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - numberOfNewAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2149,6 +1995,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2157,51 +2005,26 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { bar: false, start: '1969-12-31T06:00:00.000Z', duration: '64800000000000' }, }) ); @@ -2246,6 +2069,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2254,48 +2079,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - consumer: 'bar', - instanceId: '2', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, + id: '2', + group: 'default', + state: { bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2332,6 +2138,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2340,53 +2148,32 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: MOCK_DURATION, - start: DATE_1969, - end: DATE_1970, - consumer: 'bar', - instanceId: '1', + state: { bar: false, start: DATE_1969, end: DATE_1970, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + end: DATE_1970, + duration: '64800000000000', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2425,6 +2212,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2433,47 +2222,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '1', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2493,6 +2264,8 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -2539,17 +2312,10 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'ok', + }); + expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update ).toHaveBeenCalledWith(...generateSavedObjectParams({})); @@ -2570,6 +2336,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ ...SAVED_OBJECT, @@ -2579,28 +2347,14 @@ describe('Task Runner', () => { expect(runnerResult.state.previousStartedAt?.toISOString()).toBe(state.previousStartedAt); expect(runnerResult.schedule).toStrictEqual(mockedTaskInstance.schedule); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - errorMessage: 'Rule failed to execute because rule ran after it was disabled.', - action: EVENT_LOG_ACTIONS.execute, - consumer: 'bar', - outcome: 'failure', - task: true, - reason: 'disabled', - status: 'error', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'disabled', + errorMessage: `Rule failed to execute because rule ran after it was disabled.`, + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2611,6 +2365,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -2625,6 +2380,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); ruleType.executor.mockImplementation( @@ -2651,6 +2408,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -2684,6 +2443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2767,6 +2528,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -2805,87 +2567,40 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "4" because the maximum number of allowed actions has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(7); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + newAlerts: 1, + activeAlerts: 1, + triggeredActions: actionsConfigMap.default.max, + generatedActions: mockActions.length, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 2, + logAction: 3, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'default', - instanceId: '1', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('3')], - actionGroupId: 'default', - instanceId: '1', - actionId: '3', - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2' }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 7, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'warning', - numberOfTriggeredActions: actionsConfigMap.default.max, - numberOfGeneratedActions: mockActions.length, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 3, + generateActionOpts({ id: '3' }) ); }); @@ -2966,6 +2681,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -3017,8 +2733,16 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(11); + testAlertingEventLogCalls({ + newAlerts: 2, + activeAlerts: 2, + generatedActions: 10, + triggeredActions: 5, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 4, + logAction: 5, + }); }); test('increments monitoring metrics after execution', async () => { @@ -3028,6 +2752,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', @@ -3067,4 +2793,125 @@ describe('Task Runner', () => { expect(inMemoryMetrics.increment.mock.calls[4][0]).toBe(IN_MEMORY_METRICS.RULE_FAILURES); expect(inMemoryMetrics.increment.mock.calls[5][0]).toBe(IN_MEMORY_METRICS.RULE_TIMEOUTS); }); + + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + errorReason, + errorMessage = 'GENERIC ERROR MESSAGE', + executionStatus = 'succeeded', + setRuleName = true, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + executionStatus?: 'succeeded' | 'failed' | 'not-reached'; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + errorReason?: string; + errorMessage?: string; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + if (setRuleName) { + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + } else { + expect(alertingEventLogger.setRuleName).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + if (status === 'error') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: null, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + error: { + message: errorMessage, + reason: errorReason, + }, + }, + }); + } else if (status === 'warning') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'partial', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + warning: { + message: `The maximum number of actions for this rule type was reached; excess actions were not triggered.`, + reason: errorReason, + }, + }, + }); + } else { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + }, + }); + } + + if (executionStatus === 'succeeded') { + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } else if (executionStatus === 'failed') { + expect(alertingEventLogger.setExecutionFailed).toHaveBeenCalledWith( + `rule execution failure: test:1: 'rule-name'`, + errorMessage + ); + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + } else if (executionStatus === 'not-reached') { + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).not.toHaveBeenCalled(); + } }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index afed1f4c9ad09..6cd6b73b9539e 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -5,19 +5,14 @@ * 2.0. */ import apm from 'elastic-apm-node'; -import { cloneDeep, mapValues, omit, pickBy, set, without } from 'lodash'; +import { cloneDeep, mapValues, omit, pickBy, without } from 'lodash'; import type { Request } from '@hapi/hapi'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/server'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; -import { - IEvent, - SAVED_OBJECT_REL_PRIMARY, - millisToNanos, - nanosToMillis, -} from '@kbn/event-log-plugin/server'; +import { millisToNanos, nanosToMillis } from '@kbn/event-log-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; @@ -62,10 +57,6 @@ import { } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; -import { - createAlertEventLogRecordObject, - Event, -} from '../lib/create_alert_event_log_record_object'; import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring'; import { GenerateNewAndRecoveredAlertEventsParams, @@ -79,13 +70,11 @@ import { } from './types'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; -// 1,000,000 nanoseconds in 1 millisecond -const Millis2Nanos = 1000 * 1000; - export const getDefaultRuleMonitoring = (): RuleMonitoring => ({ execution: { history: [], @@ -107,7 +96,6 @@ export class TaskRunner< private context: TaskRunnerContext; private logger: Logger; private taskInstance: RuleTaskInstance; - private ruleName: string | null; private ruleConsumer: string | null; private ruleType: NormalizedRuleType< Params, @@ -121,6 +109,7 @@ export class TaskRunner< private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; private readonly inMemoryMetrics: InMemoryMetrics; + private alertingEventLogger: AlertingEventLogger; private usageCounter?: UsageCounter; private searchAbortController: AbortController; private cancelled: boolean; @@ -143,7 +132,6 @@ export class TaskRunner< this.logger = context.logger; this.usageCounter = context.usageCounter; this.ruleType = ruleType; - this.ruleName = null; this.ruleConsumer = null; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); this.ruleTypeRegistry = context.ruleTypeRegistry; @@ -151,6 +139,7 @@ export class TaskRunner< this.cancelled = false; this.executionId = uuid.v4(); this.inMemoryMetrics = inMemoryMetrics; + this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); } private async getDecryptedAttributes( @@ -231,7 +220,7 @@ export class TaskRunner< spaceId, ruleType: this.ruleType, kibanaBaseUrl, - eventLogger: this.context.eventLogger, + alertingEventLogger: this.alertingEventLogger, request, ruleParams, supportsEphemeralTasks: this.context.supportsEphemeralTasks, @@ -321,8 +310,7 @@ export class TaskRunner< rule: SanitizedRule, params: Params, executionHandler: ExecutionHandler, - spaceId: string, - event: Event + spaceId: string ): Promise { const { alertTypeId, @@ -358,7 +346,6 @@ export class TaskRunner< const originalAlerts = cloneDeep(alerts); const originalAlertIds = new Set(Object.keys(originalAlerts)); - const eventLogger = this.context.eventLogger; const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); @@ -440,22 +427,15 @@ export class TaskRunner< }) ); } catch (err) { - event.message = `rule execution failure: ${ruleLabel}`; - event.error = event.error || {}; - event.error.message = err.message; - event.event = event.event || {}; - event.event.outcome = 'failure'; + this.alertingEventLogger.setExecutionFailed( + `rule execution failure: ${ruleLabel}`, + err.message + ); throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Execute, err); } - event.message = `rule executed: ${ruleLabel}`; - event.event = event.event || {}; - event.event.outcome = 'success'; - event.rule = { - ...event.rule, - name: rule.name, - }; + this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); const ruleRunMetricsStore = new RuleRunMetricsStore(); @@ -488,17 +468,11 @@ export class TaskRunner< if (this.shouldLogAndScheduleActionsForAlerts()) { generateNewAndRecoveredAlertEvents({ - eventLogger, - executionId: this.executionId, + alertingEventLogger: this.alertingEventLogger, originalAlerts, currentAlerts: alertsWithScheduledActions, recoveredAlerts, - ruleId, ruleLabel, - namespace, - ruleType, - rule, - spaceId, ruleRunMetricsStore, }); } @@ -584,8 +558,7 @@ export class TaskRunner< private async validateAndExecuteRule( fakeRequest: KibanaRequest, apiKey: RawRule['apiKey'], - rule: SanitizedRule, - event: Event + rule: SanitizedRule ) { const { params: { alertId: ruleId, spaceId }, @@ -604,10 +577,10 @@ export class TaskRunner< rule.params, fakeRequest ); - return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId, event); + return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId); } - private async loadRuleAttributesAndRun(event: Event): Promise> { + private async loadRuleAttributesAndRun(): Promise> { const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; @@ -657,7 +630,7 @@ export class TaskRunner< throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err); } - this.ruleName = rule.name; + this.alertingEventLogger.setRuleName(rule.name); try { this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); @@ -674,7 +647,7 @@ export class TaskRunner< return { monitoring: asOk(rule.monitoring), stateWithMetrics: await promiseResult( - this.validateAndExecuteRule(fakeRequest, apiKey, rule, event) + this.validateAndExecuteRule(fakeRequest, apiKey, rule) ), schedule: asOk( // fetch the rule again to ensure we return the correct schedule as it may have @@ -716,46 +689,21 @@ export class TaskRunner< this.logger.debug(`executing rule ${this.ruleType.id}:${ruleId} at ${runDateString}`); const namespace = this.context.spaceIdToNamespace(spaceId); - const eventLogger = this.context.eventLogger; - const scheduleDelay = runDate.getTime() - this.taskInstance.scheduledAt.getTime(); - const event = createAlertEventLogRecordObject({ + this.alertingEventLogger.initialize({ ruleId, ruleType: this.ruleType as UntypedNormalizedRuleType, consumer: this.ruleConsumer!, - action: EVENT_LOG_ACTIONS.execute, - namespace, spaceId, executionId: this.executionId, - task: { - scheduled: this.taskInstance.scheduledAt.toISOString(), - scheduleDelay: Millis2Nanos * scheduleDelay, - }, - savedObjects: [ - { - id: ruleId, - type: 'alert', - typeId: this.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], - }); - - eventLogger.startTiming(event); - - const startEvent = cloneDeep({ - ...event, - event: { - ...event.event, - action: EVENT_LOG_ACTIONS.executeStart, - }, - message: `rule execution start: "${ruleId}"`, + taskScheduledAt: this.taskInstance.scheduledAt, + ...(namespace ? { namespace } : {}), }); - eventLogger.logEvent(startEvent); + this.alertingEventLogger.start(); const { stateWithMetrics, schedule, monitoring } = await errorAsRuleTaskRunResult( - this.loadRuleAttributesAndRun(event) + this.loadRuleAttributesAndRun() ); const ruleMonitoring = @@ -772,10 +720,6 @@ export class TaskRunner< (ruleRunStateWithMetrics) => executionStatusFromState(ruleRunStateWithMetrics, runDate), (err: ElasticsearchError) => executionStatusFromError(err, runDate) ); - // set the executionStatus date to same as event, if it's set - if (event.event?.start) { - executionStatus.lastExecutionDate = new Date(event.event.start); - } if (apm.currentTransaction) { if (executionStatus.status === 'ok' || executionStatus.status === 'active') { @@ -794,91 +738,27 @@ export class TaskRunner< ); } - eventLogger.stopTiming(event); - set(event, 'kibana.alerting.status', executionStatus.status); - - if (this.ruleConsumer) { - set(event, 'kibana.alert.rule.consumer', this.ruleConsumer); - } + this.alertingEventLogger.done({ status: executionStatus, metrics: executionMetrics }); const monitoringHistory: RuleMonitoringHistory = { success: true, timestamp: +new Date(), }; - // Copy duration into execution status if available - if (null != event.event?.duration) { - executionStatus.lastDuration = nanosToMillis(event.event?.duration); + // set start and duration based on event log + const { start, duration } = this.alertingEventLogger.getStartAndDuration(); + if (null != start) { + executionStatus.lastExecutionDate = start; + } + if (null != duration) { + executionStatus.lastDuration = nanosToMillis(duration); monitoringHistory.duration = executionStatus.lastDuration; } // if executionStatus indicates an error, fill in fields in // event from it if (executionStatus.error) { - set(event, 'event.reason', executionStatus.error?.reason || 'unknown'); - set(event, 'event.outcome', 'failure'); - set(event, 'error.message', event?.error?.message || executionStatus.error.message); - if (!event.message) { - event.message = `${this.ruleType.id}:${ruleId}: execution failed`; - } monitoringHistory.success = false; - } else { - if (executionStatus.warning) { - set(event, 'event.reason', executionStatus.warning?.reason || 'unknown'); - set(event, 'message', executionStatus.warning?.message || event?.message); - } - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_triggered_actions', - executionMetrics.numberOfTriggeredActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_generated_actions', - executionMetrics.numberOfGeneratedActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_active_alerts', - executionMetrics.numberOfActiveAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_new_alerts', - executionMetrics.numberOfNewAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_number_of_alerts', - (executionMetrics.numberOfActiveAlerts ?? 0) + - (executionMetrics.numberOfRecoveredAlerts ?? 0) - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_recovered_alerts', - executionMetrics.numberOfRecoveredAlerts - ); - } - } - - // Copy search stats into event log - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_searches', - executionMetrics.numSearches ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.es_search_duration_ms', - executionMetrics.esSearchDurationMs ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_search_duration_ms', - executionMetrics.totalSearchDurationMs ?? 0 - ); } ruleMonitoring.execution.history.push(monitoringHistory); @@ -887,8 +767,6 @@ export class TaskRunner< ...getExecutionDurationPercentiles(ruleMonitoring), }; - eventLogger.logEvent(event); - if (!this.cancelled) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS); if (executionStatus.error) { @@ -982,48 +860,7 @@ export class TaskRunner< ); this.searchAbortController.abort(); - const eventLogger = this.context.eventLogger; - const event: IEvent = { - event: { - action: EVENT_LOG_ACTIONS.executeTimeout, - kind: 'alert', - category: [this.ruleType.producer], - }, - message: `rule: ${this.ruleType.id}:${ruleId}: '${ - this.ruleName ?? '' - }' execution cancelled due to timeout - exceeded rule type timeout of ${ - this.ruleType.ruleTaskTimeout - }`, - kibana: { - alert: { - rule: { - ...(this.ruleConsumer ? { consumer: this.ruleConsumer } : {}), - execution: { - uuid: this.executionId, - }, - rule_type_id: this.ruleType.id, - }, - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: this.ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - rule: { - id: ruleId, - license: this.ruleType.minimumLicenseRequired, - category: this.ruleType.id, - ruleset: this.ruleType.producer, - ...(this.ruleName ? { name: this.ruleName } : {}), - }, - }; - eventLogger.logEvent(event); + this.alertingEventLogger.logTimeout(); this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_TIMEOUTS); @@ -1096,16 +933,10 @@ function generateNewAndRecoveredAlertEvents< InstanceContext extends AlertInstanceContext >(params: GenerateNewAndRecoveredAlertEventsParams) { const { - eventLogger, - executionId, - ruleId, - namespace, + alertingEventLogger, currentAlerts, originalAlerts, recoveredAlerts, - rule, - ruleType, - spaceId, ruleRunMetricsStore, } = params; const originalAlertIds = Object.keys(originalAlerts); @@ -1128,14 +959,15 @@ function generateNewAndRecoveredAlertEvents< recoveredAlerts[id].getLastScheduledActions() ?? {}; const state = recoveredAlerts[id].getState(); const message = `${params.ruleLabel} alert '${id}' has recovered`; - logAlertEvent( + + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.recoveredInstance, id, - EVENT_LOG_ACTIONS.recoveredInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); + }); } for (const id of newIds) { @@ -1143,7 +975,14 @@ function generateNewAndRecoveredAlertEvents< currentAlerts[id].getScheduledActionOptions() ?? {}; const state = currentAlerts[id].getState(); const message = `${params.ruleLabel} created new alert: '${id}'`; - logAlertEvent(id, EVENT_LOG_ACTIONS.newInstance, message, state, actionGroup, actionSubgroup); + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.newInstance, + id, + group: actionGroup, + subgroup: actionSubgroup, + message, + state, + }); } for (const id of currentAlertIds) { @@ -1155,69 +994,14 @@ function generateNewAndRecoveredAlertEvents< ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` : `actionGroup: '${actionGroup}'` }`; - logAlertEvent( + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.activeInstance, id, - EVENT_LOG_ACTIONS.activeInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); - } - - function logAlertEvent( - alertId: string, - action: string, - message: string, - state: InstanceState, - group?: string, - subgroup?: string - ) { - const event: IEvent = { - event: { - action, - kind: 'alert', - category: [ruleType.producer], - ...(state?.start ? { start: state.start as string } : {}), - ...(state?.end ? { end: state.end as string } : {}), - ...(state?.duration !== undefined ? { duration: state.duration as string } : {}), - }, - kibana: { - alert: { - rule: { - consumer: rule.consumer, - execution: { - uuid: executionId, - }, - rule_type_id: ruleType.id, - }, - }, - alerting: { - instance_id: alertId, - ...(group ? { action_group_id: group } : {}), - ...(subgroup ? { action_subgroup: subgroup } : {}), - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - message, - rule: { - id: rule.id, - license: ruleType.minimumLicenseRequired, - category: ruleType.id, - ruleset: ruleType.producer, - name: rule.name, - }, - }; - eventLogger.logEvent(event); + }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index fdb3c8e756003..fb2d1be3a3872 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -14,7 +14,7 @@ import { AlertInstanceState, AlertInstanceContext, } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -32,11 +32,23 @@ import { actionsMock, actionsClientMock } from '@kbn/actions-plugin/server/mocks import { alertsMock, rulesClientMock } from '../mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { IEventLogger } from '@kbn/event-log-plugin/server'; -import { Rule, RecoveredActionGroup } from '../../common'; -import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { + mockTaskInstance, + ruleType, + mockedRuleTypeSavedObject, + generateAlertOpts, + DATE_1970, + generateActionOpts, +} from './fixtures'; +import { EVENT_LOG_ACTIONS } from '../plugin'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -45,48 +57,29 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); -const ruleType: jest.Mocked = { - id: 'test', - name: 'My test rule', - actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: RecoveredActionGroup, - executor: jest.fn(), - producer: 'alerts', - cancelAlertsOnRuleTimeout: true, - ruleTaskTimeout: '5m', -}; +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner Cancel', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); - mockedTaskInstance = { - id: '', - attempts: 0, - status: TaskStatus.Running, - version: '123', - runAt: new Date(), - schedule: { interval: '10s' }, - scheduledAt: new Date(), - startedAt: new Date(), - retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: {}, - taskType: 'alerting:test', - params: { - alertId: '1', - spaceId: 'default', - consumer: 'bar', - }, - ownerId: null, + mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, }; }); @@ -136,53 +129,6 @@ describe('Task Runner Cancel', () => { }, }; - const mockDate = new Date('2019-02-12T21:01:22.479Z'); - - const mockedRuleSavedObject: Rule = { - id: '1', - consumer: 'bar', - createdAt: mockDate, - updatedAt: mockDate, - throttle: null, - muteAll: false, - notifyWhen: 'onActiveAlert', - enabled: true, - alertTypeId: ruleType.id, - apiKey: '', - apiKeyOwner: 'elastic', - schedule: { interval: '10s' }, - name: 'rule-name', - tags: ['rule-', '-tags'], - createdBy: 'rule-creator', - updatedBy: 'rule-updater', - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - id: '1', - actionTypeId: 'action', - params: { - foo: true, - }, - }, - { - group: RecoveredActionGroup.id, - id: '2', - actionTypeId: 'action', - params: { - isResolved: true, - }, - }, - ], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - }; - beforeEach(() => { jest.resetAllMocks(); jest @@ -208,7 +154,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); - rulesClient.get.mockResolvedValue(mockedRuleSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -221,6 +167,8 @@ describe('Task Runner Cancel', () => { }); taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('updates rule saved object execution status and writes to event log entry when task is cancelled mid-execution', async () => { @@ -230,6 +178,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); @@ -242,136 +191,7 @@ describe('Task Runner Cancel', () => { `Aborting any in-progress ES searches for rule type test with id 1` ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - // execute-start event, timeout event and then an execute event because rule executors are not cancelling anything yet - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: 'rule execution start: "1"', - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_new_alerts: 0, - number_of_recovered_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'ok', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: `rule executed: test:1: 'rule-name'`, - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -426,22 +246,50 @@ describe('Task Runner Cancel', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + status: 'active', + newAlerts: 1, + activeAlerts: 1, + generatedActions: 1, + triggeredActions: 1, + logAction: 1, + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('actionsPlugin.execute is called if rule execution is cancelled but cancelAlertsOnRuleTimeout for ruleType is false', async () => { - ruleTypeRegistry.get.mockReturnValue({ + const updatedRuleType = { ...ruleType, cancelAlertsOnRuleTimeout: false, - }); + }; + ruleTypeRegistry.get.mockReturnValue(updatedRuleType); ruleType.executor.mockImplementation( async ({ services: executorServices, @@ -457,21 +305,47 @@ describe('Task Runner Cancel', () => { ); // setting cancelAlertsOnRuleTimeout for ruleType to false here const taskRunner = new TaskRunner( - { - ...ruleType, - cancelAlertsOnRuleTimeout: false, - }, + updatedRuleType, mockedTaskInstance, taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + ruleContext: { ...alertingEventLoggerInitializer, ruleType: updatedRuleType }, + status: 'active', + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + logAlert: 2, + logAction: 1, + }); + + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -496,174 +370,15 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(8); - expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `Cancelling rule type test with id 1 - execution exceeded rule type timeout of 5m` - ); - expect(logger.debug).nthCalledWith( - 3, - 'Aborting any in-progress ES searches for rule type test with id 1' - ); - expect(logger.debug).nthCalledWith( - 4, - `Updating rule task for test rule with id 1 - execution error due to timeout` - ); - expect(logger.debug).nthCalledWith( - 5, - `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 6, - `no scheduling of actions for rule test:1: 'rule-name': rule execution has been cancelled.` - ); - expect(logger.debug).nthCalledWith( - 7, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); - expect(logger.debug).nthCalledWith( - 8, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' - ); - - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: \"1\"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_recovered_alerts: 0, - number_of_new_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, + testAlertingEventLogCalls({ + status: 'active', }); expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); @@ -673,7 +388,7 @@ describe('Task Runner Cancel', () => { }); }); - function testActionsExecute() { + function testLogger() { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); @@ -701,256 +416,69 @@ describe('Task Runner Cancel', () => { 7, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); + } - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: "1"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'new-instance', - category: ['alerts'], - kind: 'alert', - duration: '0', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' created new alert: '1'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { - event: { - action: 'active-instance', - category: ['alerts'], - duration: '0', - kind: 'alert', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { - event: { - action: 'execute-action', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - instance_id: '1', - action_group_id: 'default', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - { - id: '1', - type: 'action', - type_id: 'action', - }, - ], - space_ids: ['default'], - }, - message: - "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(6, { - event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 1, - number_of_generated_actions: 1, - number_of_active_alerts: 1, - number_of_new_alerts: 1, - number_of_recovered_alerts: 0, - total_number_of_alerts: 1, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, }, }); + + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).toHaveBeenCalled(); } }); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 1f4a31fa1d9ac..d3c6038474a38 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -8,8 +8,8 @@ import { Dictionary } from 'lodash'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { IEventLogger } from '@kbn/event-log-plugin/server'; import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { ActionGroup, RuleAction, @@ -20,7 +20,6 @@ import { IntervalSchedule, RuleMonitoring, RuleTaskState, - SanitizedRule, } from '../../common'; import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; @@ -28,6 +27,7 @@ import { ExecutionHandler } from './create_execution_handler'; import { RawRule } from '../types'; import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; export interface RuleTaskRunResult { state: RuleTaskState; @@ -61,29 +61,11 @@ export interface GenerateNewAndRecoveredAlertEventsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext > { - eventLogger: IEventLogger; - executionId: string; + alertingEventLogger: AlertingEventLogger; originalAlerts: Dictionary>; currentAlerts: Dictionary>; recoveredAlerts: Dictionary>; - ruleId: string; ruleLabel: string; - namespace: string | undefined; - ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - { - [x: string]: unknown; - }, - { - [x: string]: unknown; - }, - string, - string - >; - rule: SanitizedRule; - spaceId: string; ruleRunMetricsStore: RuleRunMetricsStore; } @@ -145,7 +127,7 @@ export interface CreateExecutionHandlerOptions< RecoveryActionGroupId >; logger: Logger; - eventLogger: IEventLogger; + alertingEventLogger: PublicMethodsOf; request: KibanaRequest; ruleParams: RuleTypeParams; supportsEphemeralTasks: boolean; diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx index 238ffc760d93f..a9ec9778ed3e6 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx @@ -13,7 +13,6 @@ import { EuiText, EuiCodeBlock, EuiTabbedContent, - EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ComponentType } from 'react'; @@ -32,6 +31,7 @@ import type { } from '../apm_policy_form/typings'; import { getCommands } from '../../../tutorial/config_agent/commands/get_commands'; import { renderMustache } from './render_mustache'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; function AccordionButtonContent({ agentName, @@ -240,19 +240,7 @@ export function AgentInstructionsAccordion({ )} - + ), diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx new file mode 100644 index 0000000000000..e4e6f7062ffdb --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; +import { Meta, Story } from '@storybook/react'; +import { CoreStart } from '@kbn/core/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { EditAPMPolicyForm } from './edit_apm_policy_form'; +import { NewPackagePolicy, PackagePolicy } from './typings'; + +const coreMock = { + http: { get: async () => ({}) }, + notifications: { toasts: { add: () => {} } }, + uiSettings: { get: () => {} }, +} as unknown as CoreStart; + +const KibanaReactContext = createKibanaReactContext(coreMock); + +const stories: Meta<{}> = { + title: 'fleet/Edit APM policy', + component: EditAPMPolicyForm, + decorators: [ + (StoryComponent) => { + return ( +
+ + + +
+ ); + }, + ], +}; +export default stories; + +export const EditAPMPolicy: Story = () => { + const [newPolicy, setNewPolicy] = useState(policy); + const [isPolicyValid, setIsPolicyValid] = useState(true); + + return ( + <> +
+
+          {`Is Policy valid: ${isPolicyValid} (when false, "Save integration" button is disabled)`}
+        
+
+ { + setIsPolicyValid(value.isValid); + const updatedVars = value.updatedPolicy.inputs?.[0].vars; + setNewPolicy((state) => ({ + ...state, + inputs: [{ ...state.inputs[0], vars: updatedVars }], + })); + }} + /> +
+
+
{JSON.stringify(newPolicy, null, 4)}
+ + ); +}; + +const policy = { + version: 'WzM2OTksMl0=', + name: 'Elastic APM', + namespace: 'default', + enabled: true, + policy_id: 'policy-elastic-agent-on-cloud', + output_id: '', + package: { + name: 'apm', + version: '8.3.0', + title: 'Elastic APM', + }, + elasticsearch: { + privileges: { + cluster: ['cluster:monitor/main'], + }, + }, + inputs: [ + { + type: 'apm', + enabled: true, + config: { + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [], + }, + }, + agent_config: [], + }, + }, + }, + streams: [], + vars: { + host: { + type: 'text', + value: '0.0.0.0:8200', + }, + url: { + type: 'text', + value: 'cloud_apm_url_test', + }, + secret_token: { + type: 'text', + value: 'asdfkjhasdf', + }, + api_key_enabled: { + type: 'bool', + value: true, + }, + enable_rum: { + type: 'bool', + value: true, + }, + anonymous_enabled: { + type: 'bool', + value: true, + }, + anonymous_allow_agent: { + type: 'text', + value: ['rum-js', 'js-base', 'iOS/swift'], + }, + anonymous_allow_service: { + type: 'text', + value: '', + }, + anonymous_rate_limit_event_limit: { + type: 'integer', + value: 300, + }, + anonymous_rate_limit_ip_limit: { + type: 'integer', + value: 1000, + }, + default_service_environment: { + type: 'text', + value: '', + }, + rum_allow_origins: { + type: 'text', + value: ['"*"'], + }, + rum_allow_headers: { + type: 'text', + value: '', + }, + rum_response_headers: { + type: 'yaml', + value: '', + }, + rum_library_pattern: { + type: 'text', + value: '"node_modules|bower_components|~"', + }, + rum_exclude_from_grouping: { + type: 'text', + value: '"^/webpack"', + }, + api_key_limit: { + type: 'integer', + value: 100, + }, + max_event_bytes: { + type: 'integer', + value: 307200, + }, + capture_personal_data: { + type: 'bool', + value: true, + }, + max_header_bytes: { + type: 'integer', + value: 1048576, + }, + idle_timeout: { + type: 'text', + value: '45s', + }, + read_timeout: { + type: 'text', + value: '3600s', + }, + shutdown_timeout: { + type: 'text', + value: '30s', + }, + write_timeout: { + type: 'text', + value: '30s', + }, + max_connections: { + type: 'integer', + value: 0, + }, + response_headers: { + type: 'yaml', + value: '', + }, + expvar_enabled: { + type: 'bool', + value: false, + }, + java_attacher_discovery_rules: { + type: 'yaml', + value: '', + }, + java_attacher_agent_version: { + type: 'text', + value: '', + }, + java_attacher_enabled: { + type: 'bool', + value: false, + }, + tls_enabled: { + type: 'bool', + value: false, + }, + tls_certificate: { + type: 'text', + value: '', + }, + tls_key: { + type: 'text', + value: '', + }, + tls_supported_protocols: { + type: 'text', + value: ['TLSv1.0', 'TLSv1.1', 'TLSv1.2'], + }, + tls_cipher_suites: { + type: 'text', + value: '', + }, + tls_curve_types: { + type: 'text', + value: '', + }, + tail_sampling_policies: { + type: 'yaml', + value: '- sample_rate: 0.1\n', + }, + tail_sampling_interval: { + type: 'text', + value: '1m', + }, + tail_sampling_enabled: { + type: 'bool', + value: false, + }, + }, + }, + ], +} as NewPackagePolicy; diff --git a/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx new file mode 100644 index 0000000000000..7068c9c6fe793 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function TechnicalPreviewBadge() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts new file mode 100644 index 0000000000000..61d12ba730942 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; + +export async function getMetricIndices({ + infraPlugin, + savedObjectsClient, +}: { + infraPlugin: Required; + savedObjectsClient: SavedObjectsClientContract; +}): Promise { + const infra = await infraPlugin.start(); + const metricIndices = await infra.getMetricIndices(savedObjectsClient); + + return metricIndices; +} diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts index d375886728748..73258ef2008fa 100644 --- a/x-pack/plugins/apm/server/types.ts +++ b/x-pack/plugins/apm/server/types.ts @@ -49,6 +49,7 @@ import { FleetSetupContract as FleetPluginSetup, FleetStartContract as FleetPluginStart, } from '@kbn/fleet-plugin/server'; +import { InfraPluginStart, InfraPluginSetup } from '@kbn/infra-plugin/server'; import { APMConfig } from '.'; import { ApmIndicesConfig } from './routes/settings/apm_indices/get_apm_indices'; import { APMEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; @@ -71,6 +72,7 @@ export interface APMPluginSetupDependencies { licensing: LicensingPluginSetup; observability: ObservabilityPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; + infra: InfraPluginSetup; // optional dependencies actions?: ActionsPlugin['setup']; @@ -92,6 +94,7 @@ export interface APMPluginStartDependencies { licensing: LicensingPluginStart; observability: undefined; ruleRegistry: RuleRegistryPluginStartContract; + infra: InfraPluginStart; // optional dependencies actions?: ActionsPlugin['start']; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts index 2554d2efd1bc9..a74b4c262dd79 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts @@ -163,10 +163,12 @@ export function embeddableFunctionFactory({ return state; }, - migrations: mapValues< - MigrateFunctionsObject, - MigrateFunction - >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable), + migrations: () => { + return mapValues< + MigrateFunctionsObject, + MigrateFunction + >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable); + }, }; }; } diff --git a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts index db87909fbd44f..c9f9ea8453e5f 100644 --- a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts +++ b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts @@ -32,7 +32,7 @@ export const customElementType = (deps: CanvasSavedObjectTypeMigrationsDeps): Sa '@created': { type: 'date' }, }, }, - migrations: customElementMigrationsFactory(deps), + migrations: () => customElementMigrationsFactory(deps), management: { icon: 'canvasApp', defaultSearchField: 'name', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts index b23ea62f88954..6c392aaaa62b1 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.ts @@ -32,7 +32,7 @@ export const workpadTypeFactory = ( '@created': { type: 'date' }, }, }, - migrations: workpadMigrationsFactory(deps), + migrations: () => workpadMigrationsFactory(deps), management: { importableAndExportable: true, icon: 'canvasApp', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts index ec852113e1ca4..224af9bed6759 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts @@ -50,7 +50,7 @@ export const workpadTemplateType = ( }, }, }, - migrations: templateWorkpadMigrationsFactory(deps), + migrations: () => templateWorkpadMigrationsFactory(deps), management: { importableAndExportable: false, icon: 'canvasApp', diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index b3dbe4801f544..ec855d98e7144 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -170,6 +170,10 @@ export const CasesFindRequestRt = rt.partial({ * The status of the case (open, closed, in-progress) */ status: CaseStatusRt, + /** + * The severity of the case + */ + severity: CaseSeverityRt, /** * The reporters to filter by */ diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 7ed9bfb3f2294..5443302bce467 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -20,6 +20,7 @@ import { CasesFindResponse, CasesStatusResponse, CasesMetricsResponse, + CaseSeverity, } from '../api'; import { SnakeToCamelCase } from '../types'; @@ -45,6 +46,9 @@ export type StatusAllType = typeof StatusAll; export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType; +export const SeverityAll = 'all' as const; +export type CaseSeverityWithAll = CaseSeverity | typeof SeverityAll; + /** * The type for the `refreshRef` prop (a `React.Ref`) defined by the `CaseViewComponentProps`. * @@ -84,6 +88,7 @@ export interface QueryParams { export interface FilterOptions { search: string; + severity: CaseSeverityWithAll; status: CaseStatusWithAllStatus; tags: string[]; reporters: User[]; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index f0a3502fd6813..22e12d5ee11b5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -204,6 +204,11 @@ describe('AllCasesListGeneric', () => { .childAt(0) .prop('value') ).toBe(useGetCasesMockState.data.cases[0].createdAt); + + expect( + wrapper.find(`[data-test-subj="case-table-column-severity"]`).first().text().toLowerCase() + ).toBe(useGetCasesMockState.data.cases[0].severity); + expect(wrapper.find(`[data-test-subj="case-table-case-count"]`).first().text()).toEqual( 'Showing 10 cases' ); @@ -223,6 +228,7 @@ describe('AllCasesListGeneric', () => { createdAt: null, createdBy: null, status: null, + severity: null, tags: null, title: null, totalComment: null, diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 5eac485e24c7b..96b220283b452 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -218,6 +218,7 @@ export const AllCasesList = React.memo( tags: filterOptions.tags, status: filterOptions.status, owner: filterOptions.owner, + severity: filterOptions.severity, }} setFilterRefetch={setFilterRefetch} hiddenStatuses={hiddenStatuses} diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 543e6ef6f4871..43096d3de061c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -18,12 +18,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiHealth, } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { Case, DeleteCase } from '../../../common/ui/types'; -import { CaseStatuses, ActionConnector } from '../../../common/api'; +import { CaseStatuses, ActionConnector, CaseSeverity } from '../../../common/api'; import { OWNER_INFO } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; @@ -40,6 +41,7 @@ import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { severities } from '../severity/config'; export type CasesColumns = | EuiTableActionsColumnType @@ -300,30 +302,6 @@ export const useCasesColumns = ({ return getEmptyTagValue(); }, }, - ...(isSelectorView - ? [ - { - align: RIGHT_ALIGNMENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ( - { - assignCaseAction(theCase); - }} - size="s" - fill={true} - > - {i18n.SELECT} - - ); - } - return getEmptyTagValue(); - }, - }, - ] - : []), ...(!isSelectorView ? [ { @@ -351,6 +329,45 @@ export const useCasesColumns = ({ }, ] : []), + { + name: i18n.SEVERITY, + render: (theCase: Case) => { + if (theCase.severity != null) { + const severityData = severities[theCase.severity ?? CaseSeverity.LOW]; + return ( + + {severityData.label} + + ); + } + return getEmptyTagValue(); + }, + }, + + ...(isSelectorView + ? [ + { + align: RIGHT_ALIGNMENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ( + { + assignCaseAction(theCase); + }} + size="s" + fill={true} + > + {i18n.SELECT} + + ); + } + return getEmptyTagValue(); + }, + }, + ] + : []), ...(userCanCrud && !isSelectorView ? [ { diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx new file mode 100644 index 0000000000000..7366bb3fceebb --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.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 { CaseSeverity } from '../../../common/api'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/dom'; +import { SeverityFilter } from './severity_filter'; + +describe('Severity form field', () => { + const onSeverityChange = jest.fn(); + let appMockRender: AppMockRenderer; + const props = { + isLoading: false, + selectedSeverity: CaseSeverity.LOW, + isDisabled: false, + onSeverityChange, + }; + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + it('renders', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-severity-filter')).not.toHaveAttribute('disabled'); + }); + + // default to LOW in this test configuration + it('defaults to the correct value', () => { + const result = appMockRender.render(); + // two items. one for the popover one for the selected field + expect(result.getAllByTestId('case-severity-filter-low').length).toBe(2); + }); + + it('selects the correct value when changed', async () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-high')); + await waitFor(() => { + expect(onSeverityChange).toHaveBeenCalledWith('high'); + }); + }); + + it('selects the correct value when changed (all)', async () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-all')); + await waitFor(() => { + expect(onSeverityChange).toHaveBeenCalledWith('all'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx new file mode 100644 index 0000000000000..a9f4a6565c318 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, +} from '@elastic/eui'; +import React from 'react'; +import { CaseSeverityWithAll, SeverityAll } from '../../containers/types'; +import { severitiesWithAll } from '../severity/config'; + +interface Props { + selectedSeverity: CaseSeverityWithAll; + onSeverityChange: (status: CaseSeverityWithAll) => void; + isLoading: boolean; + isDisabled: boolean; +} + +export const SeverityFilter: React.FC = ({ + selectedSeverity, + onSeverityChange, + isLoading, + isDisabled, +}) => { + const caseSeverities = Object.keys(severitiesWithAll) as CaseSeverityWithAll[]; + const options: Array> = caseSeverities.map( + (severity) => { + const severityData = severitiesWithAll[severity]; + return { + value: severity, + inputDisplay: ( + + + {severity === SeverityAll ? ( + {severityData.label} + ) : ( + {severityData.label} + )} + + + ), + }; + } + ); + + return ( + + ); +}; +SeverityFilter.displayName = 'SeverityFilter'; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 5e83c33717abd..ff1c00b56d031 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -10,11 +10,12 @@ import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/api'; import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { CasesTableFilters } from './table_filters'; +import userEvent from '@testing-library/user-event'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -35,7 +36,9 @@ const props = { }; describe('CasesTableFilters ', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { + appMockRender = createAppMockRenderer(); jest.clearAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags }); (useGetReporters as jest.Mock).mockReturnValue({ @@ -57,6 +60,19 @@ describe('CasesTableFilters ', () => { expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy(); }); + it('should render the case severity filter dropdown', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-severity-filter')).toBeTruthy(); + }); + + it('should call onFilterChange when the severity filter changes', () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-high')); + + expect(onFilterChanged).toBeCalledWith({ severity: 'high' }); + }); + it('should call onFilterChange when selected tags change', () => { const wrapper = mount( diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index faee469d1c4bc..0a34e756e37a6 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -10,7 +10,12 @@ import { isEqual } from 'lodash/fp'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; -import { StatusAll, CaseStatusWithAllStatus } from '../../../common/ui/types'; +import { + StatusAll, + CaseStatusWithAllStatus, + SeverityAll, + CaseSeverityWithAll, +} from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import { FilterOptions } from '../../containers/types'; import { useGetTags } from '../../containers/use_get_tags'; @@ -18,6 +23,7 @@ import { useGetReporters } from '../../containers/use_get_reporters'; import { FilterPopover } from '../filter_popover'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; +import { SeverityFilter } from './severity_filter'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -39,6 +45,12 @@ const StatusFilterWrapper = styled(EuiFlexItem)` } `; +const SeverityFilterWrapper = styled(EuiFlexItem)` + && { + flex-basis: 180px; + } +`; + /** * Collection of filters for filtering data within the CasesTable. Contains search bar, * and tag selection @@ -48,6 +60,7 @@ const StatusFilterWrapper = styled(EuiFlexItem)` const defaultInitial = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], @@ -151,6 +164,13 @@ const CasesTableFiltersComponent = ({ [onFilterChanged] ); + const onSeverityChanged = useCallback( + (severity: CaseSeverityWithAll) => { + onFilterChanged({ severity }); + }, + [onFilterChanged] + ); + const stats = useMemo( () => ({ [StatusAll]: null, @@ -181,6 +201,14 @@ const CasesTableFiltersComponent = ({ onSearch={handleOnSearch} /> + + + { }); }); + test('should apply the severity field correctly (with severity value)', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: CaseSeverity.HIGH, + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + severity: CaseSeverity.HIGH, + }, + signal: abortCtrl.signal, + }); + }); + + test('should not send the severity field with "all" severity value', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: 'all', + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + }, + signal: abortCtrl.signal, + }); + }); + test('should handle tags with weird chars', async () => { const weirdTags: string[] = ['(', '"double"']; diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 63a2ea794e065..b0f00ad202c5f 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { omit } from 'lodash'; import { Cases, FetchCasesProps, ResolvedCase, + SeverityAll, SortFieldCase, StatusAll, } from '../../common/ui/types'; @@ -149,6 +149,7 @@ export const getCaseUserActions = async ( export const getCases = async ({ filterOptions = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], @@ -163,9 +164,10 @@ export const getCases = async ({ signal, }: FetchCasesProps): Promise => { const query = { + ...(filterOptions.status !== StatusAll ? { status: filterOptions.status } : {}), + ...(filterOptions.severity !== SeverityAll ? { severity: filterOptions.severity } : {}), reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''), tags: filterOptions.tags, - status: filterOptions.status, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), ...(filterOptions.owner.length > 0 ? { owner: filterOptions.owner } : {}), ...queryParams, @@ -173,7 +175,7 @@ export const getCases = async ({ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { method: 'GET', - query: query.status === StatusAll ? omit(query, ['status']) : query, + query, signal, }); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index dee4d424c84de..b689746a7af00 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseStatuses } from '../../common/api'; +import { CaseSeverity, CaseStatuses } from '../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { DEFAULT_FILTER_OPTIONS, @@ -219,6 +219,7 @@ describe('useGetCases', () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); const newFilters = { search: 'new', + severity: CaseSeverity.LOW, tags: ['new'], status: CaseStatuses.closed, owner: [SECURITY_SOLUTION_OWNER], diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index d817dc9d9ac0f..f708d98282252 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -15,6 +15,7 @@ import { SortFieldCase, StatusAll, UpdateByKey, + SeverityAll, } from './types'; import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; @@ -101,6 +102,7 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index b5d3cee05ced6..0c22229692842 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -53,6 +53,7 @@ export const find = async ( reporters: queryParams.reporters, sortByField: queryParams.sortField, status: queryParams.status, + severity: queryParams.severity, owner: queryParams.owner, from: queryParams.from, to: queryParams.to, diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index faae6450c5238..334b974c06108 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -23,6 +23,7 @@ import { ContextTypeUserRt, excess, throwErrors, + CaseSeverity, } from '../../common/api'; import { combineFilterWithAuthorizationFilter } from '../authorization/utils'; import { @@ -114,6 +115,25 @@ export const addStatusFilter = ({ return filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; }; +export const addSeverityFilter = ({ + severity, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + severity: CaseSeverity; + appendFilter?: KueryNode; + type?: string; +}): KueryNode => { + const filters: KueryNode[] = []; + filters.push(nodeBuilder.is(`${type}.attributes.severity`, severity)); + + if (appendFilter) { + filters.push(appendFilter); + } + + return filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; +}; + interface FilterField { filters?: string | string[]; field: string; @@ -222,6 +242,7 @@ export const constructQueryOptions = ({ tags, reporters, status, + severity, sortByField, owner, authorizationFilter, @@ -231,6 +252,7 @@ export const constructQueryOptions = ({ tags?: string | string[]; reporters?: string | string[]; status?: CaseStatuses; + severity?: CaseSeverity; sortByField?: string; owner?: string | string[]; authorizationFilter?: KueryNode; @@ -250,10 +272,12 @@ export const constructQueryOptions = ({ const ownerFilter = buildFilter({ filters: owner ?? [], field: OWNER_FIELD, operator: 'or' }); const statusFilter = status != null ? addStatusFilter({ status }) : undefined; + const severityFilter = severity != null ? addSeverityFilter({ severity }) : undefined; const rangeFilter = buildRangeFilter({ from, to }); const filters: KueryNode[] = [ statusFilter, + severityFilter, tagsFilter, reportersFilter, rangeFilter, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index b69303aae2106..0213aa26d5ef3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -21,6 +21,8 @@ import { SOURCES_PATH, PRIVATE_SOURCES_PATH, SOURCE_DETAILS_PATH, + getAddPath, + getEditPath, } from './routes'; const TestComponent = ({ id, isOrg }: { id: string; isOrg?: boolean }) => { @@ -86,3 +88,32 @@ describe('getReindexJobRoute', () => { ); }); }); + +describe('getAddPath', () => { + it('should handle a service type', () => { + expect(getAddPath('share_point')).toEqual('/sources/add/share_point'); + }); + + it('should should handle an external service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle an external service type with a base service type', () => { + expect(getAddPath('external', 'share_point')).toEqual('/sources/add/share_point/external'); + }); + it('should should handle a custom service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle a custom service type with a base service type', () => { + expect(getAddPath('custom', 'share_point_server')).toEqual( + '/sources/add/share_point_server/custom' + ); + }); +}); + +describe('getEditPath', () => { + it('should handle a service type', () => { + expect(getEditPath('share_point')).toEqual('/settings/connectors/share_point/edit'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index cbcd1d885b120..fe1be10aa3b06 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -77,6 +77,14 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); -export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; + +export const getAddPath = (serviceType: string, baseServiceType?: string): string => { + const baseServiceTypePath = baseServiceType + ? `${baseServiceType}/${serviceType}` + : `${serviceType}`; + return `${SOURCES_PATH}/add/${baseServiceTypePath}`; +}; + +// TODO this should handle base service type once we are getting it back from registered external connectors export const getEditPath = (serviceType: string): string => `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 984e6664681b4..32353230b36aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -72,18 +72,14 @@ export interface Configuration { export interface SourceDataItem { name: string; - iconName: string; categories?: string[]; serviceType: string; + baseServiceType?: string; configuration: Configuration; - configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; accountContextOnly: boolean; - internalConnectorAvailable?: boolean; - externalConnectorAvailable?: boolean; - customConnectorAvailable?: boolean; isBeta?: boolean; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts deleted file mode 100644 index fbfda1ddf8d5e..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts +++ /dev/null @@ -1,17 +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 { SourceDataItem } from '../types'; - -export const hasMultipleConnectorOptions = ({ - internalConnectorAvailable, - externalConnectorAvailable, - customConnectorAvailable, -}: SourceDataItem) => - [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( - (available) => !!available - ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index c66a6d1ca0fc0..6f6af758c0283 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,6 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; -export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; export { isNotNullish } from './is_not_nullish'; export { sortByName } from './sort_by_name'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx index b606f9d7f56fd..9ff64dfe4f65b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -17,7 +18,6 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { AddCustomSource } from './add_custom_source'; import { AddCustomSourceSteps } from './add_custom_source_logic'; @@ -25,11 +25,6 @@ import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; describe('AddCustomSource', () => { - const props = { - sourceData: staticSourceData[0], - initialValues: undefined, - }; - const values = { sourceConfigData, isOrganization: true, @@ -37,17 +32,26 @@ describe('AddCustomSource', () => { beforeEach(() => { setMockValues({ ...values }); + mockUseParams.mockReturnValue({ baseServiceType: 'share_point_server' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('should show correct layout for personal dashboard', () => { setMockValues({ isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); @@ -55,14 +59,14 @@ describe('AddCustomSource', () => { it('should show Configure Custom for custom configuration step', () => { setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigureCustom)).toHaveLength(1); }); it('should show Save Custom for save custom step', () => { setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SaveCustom)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx index c2f6afba032c7..b15129665a7d4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; + import { useValues } from 'kea'; import { AppLogic } from '../../../../../app_logic'; @@ -16,27 +18,38 @@ import { } from '../../../../../components/layout'; import { NAV } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; +import { getSourceData } from '../../../source_data'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; -interface Props { - sourceData: SourceDataItem; - initialValue?: string; -} -export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { - const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); +export const AddCustomSource: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('custom', baseServiceType); + + const addCustomSourceLogic = AddCustomSourceLogic({ + baseServiceType, + initialValue: sourceData?.name, + }); + const { currentStep } = useValues(addCustomSourceLogic); const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - - {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } - {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && ( + + )} + {currentStep === AddCustomSourceSteps.SaveCustomStep && ( + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts index d2187bd0b21a1..2ca3462da0f57 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts @@ -14,7 +14,6 @@ import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock' import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; jest.mock('../../../../../app_logic', () => ({ @@ -22,35 +21,17 @@ jest.mock('../../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../../app_logic'; -import { SOURCE_NAMES } from '../../../../../constants'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; -const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { - name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, -}; - const DEFAULT_VALUES = { currentStep: AddCustomSourceSteps.ConfigureCustomStep, buttonLoading: false, customSourceNameValue: '', newCustomSource: {} as CustomSource, - sourceData: CUSTOM_SOURCE_DATA_ITEM, }; -const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; - const MOCK_NAME = 'name'; describe('AddCustomSourceLogic', () => { @@ -60,7 +41,7 @@ describe('AddCustomSourceLogic', () => { beforeEach(() => { jest.clearAllMocks(); - mount({}, MOCK_PROPS); + mount({}); }); it('has expected default values', () => { @@ -112,12 +93,9 @@ describe('AddCustomSourceLogic', () => { describe('listeners', () => { beforeEach(() => { - mount( - { - customSourceNameValue: MOCK_NAME, - }, - MOCK_PROPS - ); + mount({ + customSourceNameValue: MOCK_NAME, + }); }); describe('organization context', () => { @@ -151,11 +129,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -165,7 +139,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), }); }); @@ -199,11 +173,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -215,7 +185,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts index f85e0761f51b5..5b02fffa5892d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts @@ -10,11 +10,11 @@ import { kea, MakeLogicType } from 'kea'; import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../../shared/http'; import { AppLogic } from '../../../../../app_logic'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; export interface AddCustomSourceProps { - sourceData: SourceDataItem; - initialValue: string; + baseServiceType?: string; + initialValue?: string; } export enum AddCustomSourceSteps { @@ -34,7 +34,6 @@ interface AddCustomSourceValues { currentStep: AddCustomSourceSteps; customSourceNameValue: string; newCustomSource: CustomSource; - sourceData: SourceDataItem; } /** @@ -67,7 +66,7 @@ export const AddCustomSourceLogic = kea< }, ], customSourceNameValue: [ - props.initialValue, + props.initialValue || '', { setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, }, @@ -78,7 +77,6 @@ export const AddCustomSourceLogic = kea< setNewCustomSource: (_, newCustomSource) => newCustomSource, }, ], - sourceData: [props.sourceData], }), listeners: ({ actions, values, props }) => ({ createContentSource: async () => { @@ -90,21 +88,12 @@ export const AddCustomSourceLogic = kea< const { customSourceNameValue } = values; - const baseParams = { + const params = { service_type: 'custom', name: customSourceNameValue, + base_service_type: props.baseServiceType, }; - // pre-configured custom sources have a serviceType reflecting their target service - // we submit this as `base_service_type` to keep track of - const params = - props.sourceData.serviceType === 'custom' - ? baseParams - : { - ...baseParams, - base_service_type: props.sourceData.serviceType, - }; - try { const response = await HttpLogic.values.http.post(route, { body: JSON.stringify(params), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx index 3ed60614d294a..a0713ec530b28 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx @@ -21,24 +21,24 @@ import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { const setCustomSourceNameValue = jest.fn(); const createContentSource = jest.fn(); + const sourceData = staticSourceData[1]; beforeEach(() => { setMockActions({ setCustomSourceNameValue, createContentSource }); setMockValues({ customSourceNameValue: 'name', buttonLoading: false, - sourceData: staticSourceData[1], }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); + const wrapper = shallow(); const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); input.simulate('change', { target: { value: text } }); @@ -47,7 +47,7 @@ describe('ConfigureCustom', () => { }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('EuiForm').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx index 024dd698cc0a2..4f673f56231cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx @@ -21,11 +21,13 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../../shared/doc_links'; import connectionIllustration from '../../../../../assets/connection_illustration.svg'; +import { SourceDataItem } from '../../../../../types'; import { SOURCE_NAME_LABEL } from '../../../constants'; import { AddSourceHeader } from '../add_source_header'; @@ -33,9 +35,13 @@ import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT, CONFIG_INTRO_ALT_TEXT } import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const ConfigureCustom: React.FC = () => { +interface ConfigureCustomProps { + sourceData: SourceDataItem; +} + +export const ConfigureCustom: React.FC = ({ sourceData }) => { const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); - const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx index 3de514a3e4d71..8f4e6e7205ef2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx @@ -25,18 +25,21 @@ const mockValues = { accessToken: 'token', name: 'name', }, - sourceData: staticCustomSourceData, }; +const sourceData = staticCustomSourceData; + describe('SaveCustom', () => { + beforeAll(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + }); + describe('default behavior', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues(mockValues); - - wrapper = shallow(); + wrapper = shallow(); }); it('contains a button back to the sources list', () => { @@ -52,20 +55,14 @@ describe('SaveCustom', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - ...mockValues, - sourceData: { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, - }, - }); - - wrapper = shallow(); + wrapper = shallow( + + ); }); it('includes a link to provide feedback', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx index 9e5e3ac2782ee..df62d2b2bdf16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx @@ -21,12 +21,14 @@ import { EuiCallOut, EuiLink, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonTo } from '../../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../../app_logic'; import { SOURCES_PATH, getSourcesPath } from '../../../../../routes'; +import { SourceDataItem } from '../../../../../types'; import { CustomSourceDeployment } from '../../custom_source_deployment'; @@ -35,10 +37,14 @@ import { SAVE_CUSTOM_BODY1 as READY_TO_ACCEPT_REQUESTS_LABEL } from '../constant import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const SaveCustom: React.FC = () => { - const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); +interface SaveCustomProps { + sourceData: SourceDataItem; +} + +export const SaveCustom: React.FC = ({ sourceData }) => { + const { newCustomSource } = useValues(AddCustomSourceLogic); const { isOrganization } = useValues(AppLogic); - const { serviceType, name, categories = [] } = sourceData; + const { serviceType, baseServiceType, name, categories = [] } = sourceData; return ( <> @@ -92,10 +98,10 @@ export const SaveCustom: React.FC = () => { - + - {serviceType !== 'custom' && ( + {baseServiceType && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 2d8b5192fd3b1..8f517b740b152 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -19,24 +20,15 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; describe('ExternalConnectorConfig', () => { - const goBack = jest.fn(); - const onDeleteConfig = jest.fn(); const setExternalConnectorApiKey = jest.fn(); const setExternalConnectorUrl = jest.fn(); const saveExternalConnectorConfig = jest.fn(); - const props = { - sourceData: staticSourceData[0], - goBack, - onDeleteConfig, - }; - const values = { sourceConfigData, buttonLoading: false, @@ -48,37 +40,47 @@ describe('ExternalConnectorConfig', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ setExternalConnectorApiKey, setExternalConnectorUrl, saveExternalConnectorConfig, }); setMockValues({ ...values }); + mockUseParams.mockReturnValue({}); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiSteps)).toHaveLength(1); expect(wrapper.find(EuiSteps).dive().find(ExternalConnectorFormFields)).toHaveLength(1); }); it('renders organizstion layout', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); it('should show correct layout for personal dashboard', () => { setMockValues({ ...values, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 5a2558f141ea0..0b4e34f47103b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -7,11 +7,12 @@ import React, { FormEvent } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { EuiButton, - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiForm, @@ -26,56 +27,41 @@ import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, } from '../../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; - -import { staticExternalSourceData } from '../../../source_data'; +import { NAV } from '../../../../../constants'; +import { getSourceData } from '../../../source_data'; import { AddSourceHeader } from '../add_source_header'; import { ConfigDocsLinks } from '../config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from '../constants'; +import { OAUTH_SAVE_CONFIG_BUTTON } from '../constants'; import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; -interface SaveConfigProps { - sourceData: SourceDataItem; - goBack?: () => void; - onDeleteConfig?: () => void; -} - -export const ExternalConnectorConfig: React.FC = ({ - sourceData, - goBack, - onDeleteConfig, -}) => { - const serviceType = 'external'; +export const ExternalConnectorConfig: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('external', baseServiceType); const { saveExternalConnectorConfig } = useActions(ExternalConnectorLogic); - const { - formDisabled, - buttonLoading, - externalConnectorUrl, - externalConnectorApiKey, - sourceConfigData, - urlValid, - } = useValues(ExternalConnectorLogic); + const { formDisabled, buttonLoading, externalConnectorUrl, externalConnectorApiKey, urlValid } = + useValues(ExternalConnectorLogic); const handleFormSubmission = (e: FormEvent) => { e.preventDefault(); saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); }; - const { name, categories } = sourceConfigData; - const { - configuration: { applicationLinkTitle, applicationPortalUrl }, - } = sourceData; const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const { - configuration: { documentationUrl }, - } = staticExternalSourceData; + name, + categories = [], + configuration: { applicationLinkTitle, applicationPortalUrl, documentationUrl }, + } = sourceData; const saveButton = ( @@ -83,22 +69,10 @@ export const ExternalConnectorConfig: React.FC = ({ ); - const deleteButton = ( - - {REMOVE_BUTTON} - - ); - - const backButton = {OAUTH_BACK_BUTTON}; - const formActions = ( {saveButton} - - {goBack && backButton} - {onDeleteConfig && deleteButton} - ); @@ -132,11 +106,17 @@ export const ExternalConnectorConfig: React.FC = ({ }, ]; - const header = ; + const header = ( + + ); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - + {header} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index fb09695a3529d..0603b59cc75b0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -36,10 +36,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: true, externalConnectorUrl: '', externalConnectorApiKey: '', - sourceConfigData: { - name: '', - categories: [], - }, urlValid: true, showInsecureUrlCallout: false, insecureUrl: true, @@ -52,7 +48,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: false, insecureUrl: false, dataLoading: false, - sourceConfigData, }; beforeEach(() => { @@ -87,7 +82,6 @@ describe('ExternalConnectorLogic', () => { it('saves the source config', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, - sourceConfigData, }); }); @@ -104,7 +98,6 @@ describe('ExternalConnectorLogic', () => { ...DEFAULT_VALUES_SUCCESS, externalConnectorUrl: '', insecureUrl: true, - sourceConfigData: newSourceConfigData, }); }); it('sets undefined api key to empty string', () => { @@ -119,7 +112,6 @@ describe('ExternalConnectorLogic', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, externalConnectorApiKey: '', - sourceConfigData: newSourceConfigData, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index d1e4cf7f4f008..e36b790edd8e9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -48,7 +48,6 @@ export interface ExternalConnectorValues { externalConnectorApiKey: string; externalConnectorUrl: string; urlValid: boolean; - sourceConfigData: SourceConfigData | Pick; insecureUrl: boolean; showInsecureUrlCallout: boolean; } @@ -107,12 +106,6 @@ export const ExternalConnectorLogic = kea< setShowInsecureUrlCallout: (_, showCallout) => showCallout, }, ], - sourceConfigData: [ - { name: '', categories: [] }, - { - fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, - }, - ], urlValid: [ true, { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index a7cfa81d30021..8811a68e49181 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -11,6 +11,7 @@ import { setMockActions, setMockValues, } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -22,13 +23,9 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; - import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -36,7 +33,7 @@ import { SaveConfig } from './save_config'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; - const initializeAddSource = jest.fn(); + const getSourceConfigData = jest.fn(); const setAddSourceStep = jest.fn(); const saveSourceConfig = jest.fn((_, setConfigCompletedStep) => { setConfigCompletedStep(); @@ -47,7 +44,7 @@ describe('AddSourceList', () => { const resetSourcesState = jest.fn(); const mockValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, + addSourceCurrentStep: null, sourceConfigData, dataLoading: false, newCustomSource: {}, @@ -56,68 +53,29 @@ describe('AddSourceList', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ - initializeAddSource, + getSourceConfigData, setAddSourceStep, saveSourceConfig, createContentSource, resetSourcesState, }); setMockValues(mockValues); - }); - - it('renders default state', () => { - const wrapper = shallow(); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - expect(initializeAddSource).toHaveBeenCalled(); - }); - - it('renders default state correctly when there are multiple connector options', () => { - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ChoiceStep); - }); - - it('renders default state correctly when there are multiple connector options but external connector is configured', () => { - setMockValues({ ...mockValues, externalConfigured: true }); - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); + mockUseParams.mockReturnValue({ serviceType: 'confluence_cloud' }); }); describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -125,7 +83,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -135,26 +93,24 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(false); wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('renders Config Completed step with feedback for external connectors', () => { + mockUseParams.mockReturnValue({ serviceType: 'external' }); setMockValues({ ...mockValues, sourceConfigData: { ...sourceConfigData, serviceType: 'external' }, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(true); + wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -163,13 +119,13 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); - saveConfig.prop('goBackStep')!(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); expect(saveSourceConfig).toHaveBeenCalled(); + + saveConfig.prop('goBackStep')!(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/intro'); }); it('renders Connect Instance step', () => { @@ -178,10 +134,11 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Configure Oauth step', () => { @@ -189,11 +146,11 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Reauthenticate step', () => { @@ -201,23 +158,8 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); - - it('renders Config Choice step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ChoiceStep, - }); - const wrapper = shallow(); - const advance = wrapper.find(ConfigurationChoice).prop('goToInternalStep'); - expect(advance).toBeDefined(); - if (advance) { - advance(); - } - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 4bdf8db217a7b..5b992703def61 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -7,29 +7,28 @@ import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { flashSuccessToast } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; import { AppLogic } from '../../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; import { NAV } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; - -import { hasMultipleConnectorOptions } from '../../../../utils'; +import { SOURCES_PATH, getSourcesPath, getAddPath, ADD_SOURCE_PATH } from '../../../../routes'; -import { SourcesLogic } from '../../sources_logic'; +import { getSourceData } from '../../source_data'; import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; +import { AddSourceLogic, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -37,27 +36,42 @@ import { SaveConfig } from './save_config'; import './add_source.scss'; -export const AddSource: React.FC = (props) => { - const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = - useActions(AddSourceLogic); - const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); - const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = - sourceConfigData; - const { serviceType, configuration, features, objTypes } = props.sourceData; - const addPath = getAddPath(serviceType); +export const AddSource: React.FC = () => { + const { serviceType, initialStep } = useParams<{ serviceType: string; initialStep?: string }>(); + const addSourceLogic = AddSourceLogic({ serviceType, initialStep }); + const { getSourceConfigData, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(addSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(addSourceLogic); const { isOrganization } = useValues(AppLogic); - const { externalConfigured } = useValues(SourcesLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); useEffect(() => { - initializeAddSource(props); + getSourceConfigData(); return resetSourceState; - }, []); + }, [serviceType]); + + const sourceData = getSourceData(serviceType); + + if (!sourceData) { + return null; + } + + const { configuration, features, objTypes } = sourceData; + + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); - const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); + const goToConfigurationIntro = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/intro` + ); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); - const goToChoice = () => setAddSourceStep(AddSourceSteps.ChoiceStep); const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', { @@ -66,11 +80,7 @@ export const AddSource: React.FC = (props) => { } ); - const goToConnectInstance = () => { - setAddSourceStep(AddSourceSteps.ConnectInstanceStep); - KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); - }; - + const goToConnectInstance = () => setAddSourceStep(AddSourceSteps.ConnectInstanceStep); const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -81,18 +91,6 @@ export const AddSource: React.FC = (props) => { return ( - {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.SaveConfigStep && ( = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - {addSourceCurrentStep === AddSourceSteps.ChoiceStep && ( - - )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx new file mode 100644 index 0000000000000..75b45da2b38b1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; + +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { getSourceData } from '../../source_data'; + +import { AddSourceChoice } from './add_source_choice'; +import { ConfigurationChoice } from './configuration_choice'; + +describe('AddSourceChoice', () => { + const { navigateToUrl } = mockKibanaValues; + + const mockValues = { + isOrganization: true, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('redirects to root add source path if user does not have a platinum license and the service is account context only', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + + shallow(); + + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders Config Choice step', () => { + setMockValues(mockValues); + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationChoice).prop('sourceData')).toEqual( + getSourceData('share_point') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx new file mode 100644 index 0000000000000..1034d207c9907 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.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 { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { getSourcesPath, ADD_SOURCE_PATH } from '../../../../routes'; + +import { getSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +import './add_source.scss'; + +export const AddSourceChoice: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx new file mode 100644 index 0000000000000..a7eeadf3a615e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.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 '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { AddSourceIntro } from './add_source_intro'; +import { ConfigurationIntro } from './configuration_intro'; + +describe('AddSourceList', () => { + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('sends the user to a choice view when there are multiple connector options', () => { + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual( + '/sources/add/share_point/choice' + ); + }); + + it('sends the user to the add source view by default', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual('/sources/add/slack/'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx new file mode 100644 index 0000000000000..b375f04a27f0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.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 React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getSourcesPath, ADD_SOURCE_PATH, getAddPath } from '../../../../routes'; + +import { getSourceData, hasMultipleConnectorOptions } from '../../source_data'; + +import { AddSourceHeader } from './add_source_header'; +import { ConfigurationIntro } from './configuration_intro'; + +import './add_source.scss'; + +export const AddSourceIntro: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, categories = [], accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + const to = + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/` + + (hasMultipleConnectorOptions(serviceType) ? 'choice' : ''); + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 88ca96b8c0fbf..3224628e72c73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,7 +15,6 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -23,10 +22,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; @@ -37,7 +35,6 @@ import { SourceConnectData, OrganizationsMap, AddSourceValues, - AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -47,8 +44,7 @@ describe('AddSourceLogic', () => { const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const DEFAULT_VALUES: AddSourceValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {} as AddSourceProps, + addSourceCurrentStep: null, dataLoading: true, sectionLoading: true, buttonLoading: false, @@ -62,11 +58,11 @@ describe('AddSourceLogic', () => { sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, oauthConfigCompleted: false, - currentServiceType: '', githubOrganizations: [], selectedGithubOrganizationsMap: {} as OrganizationsMap, selectedGithubOrganizations: [], preContentSourceId: '', + sourceData: staticSourceData[0], }; const sourceConnectData = { @@ -79,40 +75,13 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - const DEFAULT_SERVICE_TYPE = { - name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, - serviceType: 'box', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchBox, - applicationPortalUrl: 'https://app.box.com/developers/console', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - }; + const DEFAULT_SERVICE_TYPE = 'box'; beforeEach(() => { jest.clearAllMocks(); ExternalConnectorLogic.mount(); SourcesLogic.mount(); - mount(); + mount({}, { serviceType: 'box' }); }); it('has expected default values', () => { @@ -215,7 +184,6 @@ describe('AddSourceLogic', () => { oauthConfigCompleted: true, dataLoading: false, sectionLoading: false, - currentServiceType: config.serviceType, githubOrganizations: config.githubOrganizations, }); }); @@ -286,140 +254,90 @@ describe('AddSourceLogic', () => { }); describe('listeners', () => { - it('initializeAddSource', () => { - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); - const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); - - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box', addSourceProps); - }); - describe('setFirstStep', () => { - it('sets intro as first step', () => { + it('sets save config as first step if unconfigured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, + configured: false, + }, + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); + AddSourceLogic.actions.setFirstStep(); + + expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); + it('sets connect as first step', () => { + mount({ sourceConfigData }, { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'connect' }); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('sets configure as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'configure' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); }); - it('sets reAuthenticate as first step', () => { + it('sets reauthenticate as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'reauthenticate' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); }); - it('sets SaveConfig as first step for external connectors', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets SaveConfigStep for when external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: false, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', + it('sets connect step if configured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, configured: true, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets Connect step when configured and external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - configured: true, }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: true, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', - configured: true, - }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); - it('sets Connect step when external and fully configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - configured: true, - serviceType: 'external', - configuredFields: { clientId: 'a', clientSecret: 'b' }, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { + + it('sets connect step if external connector has client id and secret', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, serviceType: 'external', - configured: true, + configuredFields: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -541,30 +459,33 @@ describe('AddSourceLogic', () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); + it('calls API and sets values and calls setFirstStep if AddSourceProps is provided', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); const setFirstStepSpy = jest.spyOn(AddSourceLogic.actions, 'setFirstStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github', addSourceProps); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); - expect(setFirstStepSpy).toHaveBeenCalledWith(addSourceProps); + expect(setFirstStepSpy).toHaveBeenCalled(); }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); }); }); @@ -579,7 +500,7 @@ describe('AddSourceLogic', () => { ); http.get.mockReturnValue(Promise.resolve(sourceConnectData)); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: false, @@ -588,7 +509,7 @@ describe('AddSourceLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -602,7 +523,7 @@ describe('AddSourceLogic', () => { it('passes query params', () => { AddSourceLogic.actions.setSourceSubdomainValue('subdomain'); AddSourceLogic.actions.setSourceIndexPermissionsValue(true); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: true, @@ -610,7 +531,7 @@ describe('AddSourceLogic', () => { }; expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -618,7 +539,7 @@ describe('AddSourceLogic', () => { }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); }); }); @@ -833,7 +754,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const errorCallback = jest.fn(); - const serviceType = 'zendesk'; + const serviceType = 'box'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -859,7 +780,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.resolve()); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); @@ -875,7 +796,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.reject('this is an error')); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); await nextTick(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -891,10 +812,10 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); + AddSourceLogic.actions.getSourceConnectData(jest.fn()); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/github/prepare', + '/internal/workplace_search/account/sources/box/prepare', { query: {} } ); }); @@ -915,10 +836,10 @@ describe('AddSourceLogic', () => { }); it('createContentSource', () => { - AddSourceLogic.actions.createContentSource('github', jest.fn()); + AddSourceLogic.actions.createContentSource(jest.fn()); expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/account/create_source', { - body: JSON.stringify({ service_type: 'github' }), + body: JSON.stringify({ service_type: 'box' }), }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 97a58966ad76a..a087f1b78571b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -23,6 +23,7 @@ import { AppLogic } from '../../../../app_logic'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { getSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { @@ -31,20 +32,16 @@ import { } from './add_external_connector/external_connector_logic'; export interface AddSourceProps { - sourceData: SourceDataItem; - connect?: boolean; - configure?: boolean; - reAuthenticate?: boolean; + serviceType: string; + initialStep?: string; } export enum AddSourceSteps { - ConfigIntroStep = 'Config Intro', SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', ConfigureOauthStep = 'Configure Oauth', ReauthenticateStep = 'Reauthenticate', - ChoiceStep = 'Choice', } export interface OauthParams { @@ -57,10 +54,6 @@ export interface OauthParams { } export interface AddSourceActions { - initializeAddSource: (addSourceProps: AddSourceProps) => { addSourceProps: AddSourceProps }; - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => { - addSourceProps: AddSourceProps; - }; setAddSourceStep(addSourceCurrentStep: AddSourceSteps): AddSourceSteps; setSourceConfigData(sourceConfigData: SourceConfigData): SourceConfigData; setSourceConnectData(sourceConnectData: SourceConnectData): SourceConnectData; @@ -76,10 +69,9 @@ export interface AddSourceActions { setSelectedGithubOrganizations(option: string): string; resetSourceState(): void; createContentSource( - serviceType: string, successCallback: () => void, errorCallback?: () => void - ): { serviceType: string; successCallback(): void; errorCallback?(): void }; + ): { successCallback(): void; errorCallback?(): void }; saveSourceConfig( isUpdating: boolean, successCallback?: () => void @@ -89,24 +81,22 @@ export interface AddSourceActions { params: OauthParams, isOrganization: boolean ): { search: Search; params: OauthParams; isOrganization: boolean }; - getSourceConfigData( - serviceType: string, - addSourceProps?: AddSourceProps - ): { serviceType: string; addSourceProps: AddSourceProps | undefined }; - getSourceConnectData( - serviceType: string, - successCallback: (oauthUrl: string) => void - ): { serviceType: string; successCallback(oauthUrl: string): void }; + getSourceConfigData(): void; + getSourceConnectData(successCallback: (oauthUrl: string) => void): { + successCallback(oauthUrl: string): void; + }; getSourceReConnectData(sourceId: string): { sourceId: string }; getPreContentSourceConfigData(): void; setButtonNotLoading(): void; - setFirstStep(addSourceProps: AddSourceProps): { addSourceProps: AddSourceProps }; + setFirstStep(): void; } export interface SourceConfigData { serviceType: string; + baseServiceType?: string; name: string; configured: boolean; + externalConnectorServiceDescribed?: boolean; categories: string[]; needsPermissions?: boolean; privateSourcesEnabled: boolean; @@ -133,8 +123,7 @@ export interface OrganizationsMap { } export interface AddSourceValues { - addSourceProps: AddSourceProps; - addSourceCurrentStep: AddSourceSteps; + addSourceCurrentStep: AddSourceSteps | null; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; @@ -147,12 +136,12 @@ export interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; selectedGithubOrganizations: string[]; preContentSourceId: string; oauthConfigCompleted: boolean; + sourceData: SourceDataItem | null; } interface PreContentSourceResponse { @@ -161,471 +150,436 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -export const AddSourceLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'add_source_logic'], - actions: { - initializeAddSource: (addSourceProps: AddSourceProps) => ({ addSourceProps }), - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => ({ - addSourceProps, - }), - setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, - setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, - setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, - setClientIdValue: (clientIdValue: string) => clientIdValue, - setClientSecretValue: (clientSecretValue: string) => clientSecretValue, - setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setSourceLoginValue: (loginValue: string) => loginValue, - setSourcePasswordValue: (passwordValue: string) => passwordValue, - setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, - setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, - setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, - setSelectedGithubOrganizations: (option: string) => option, - getSourceConfigData: (serviceType: string, addSourceProps?: AddSourceProps) => ({ - serviceType, - addSourceProps, - }), - getSourceConnectData: (serviceType: string, successCallback: (oauthUrl: string) => string) => ({ - serviceType, - successCallback, - }), - getSourceReConnectData: (sourceId: string) => ({ sourceId }), - getPreContentSourceConfigData: () => true, - saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ - isUpdating, - successCallback, +export const AddSourceLogic = kea>( + { + path: ['enterprise_search', 'workplace_search', 'add_source_logic'], + actions: { + setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, + setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, + setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, + setClientIdValue: (clientIdValue: string) => clientIdValue, + setClientSecretValue: (clientSecretValue: string) => clientSecretValue, + setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, + setSourceLoginValue: (loginValue: string) => loginValue, + setSourcePasswordValue: (passwordValue: string) => passwordValue, + setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, + setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, + setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, + setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, + setSelectedGithubOrganizations: (option: string) => option, + getSourceConfigData: () => true, + getSourceConnectData: (successCallback: (oauthUrl: string) => string) => ({ + successCallback, + }), + getSourceReConnectData: (sourceId: string) => ({ sourceId }), + getPreContentSourceConfigData: () => true, + saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ + isUpdating, + successCallback, + }), + saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ + search, + params, + isOrganization, + }), + createContentSource: (successCallback: () => void, errorCallback?: () => void) => ({ + successCallback, + errorCallback, + }), + resetSourceState: () => true, + setButtonNotLoading: () => true, + setFirstStep: () => true, + }, + reducers: ({ props }) => ({ + addSourceCurrentStep: [ + null, + { + setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, + }, + ], + sourceConfigData: [ + {} as SourceConfigData, + { + setSourceConfigData: (_, sourceConfigData) => sourceConfigData, + }, + ], + sourceConnectData: [ + {} as SourceConnectData, + { + setSourceConnectData: (_, sourceConnectData) => sourceConnectData, + }, + ], + dataLoading: [ + true, + { + setSourceConfigData: () => false, + resetSourceState: () => false, + setPreContentSourceConfigData: () => false, + getSourceConfigData: () => true, + }, + ], + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + setSourceConnectData: () => false, + setSourceConfigData: () => false, + resetSourceState: () => false, + saveSourceConfig: () => true, + getSourceConnectData: () => true, + createContentSource: () => true, + }, + ], + sectionLoading: [ + true, + { + getPreContentSourceConfigData: () => true, + setPreContentSourceConfigData: () => false, + }, + ], + clientIdValue: [ + '', + { + setClientIdValue: (_, clientIdValue) => clientIdValue, + setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', + resetSourceState: () => '', + }, + ], + clientSecretValue: [ + '', + { + setClientSecretValue: (_, clientSecretValue) => clientSecretValue, + setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', + resetSourceState: () => '', + }, + ], + baseUrlValue: [ + '', + { + setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, + setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', + resetSourceState: () => '', + }, + ], + loginValue: [ + '', + { + setSourceLoginValue: (_, loginValue) => loginValue, + resetSourceState: () => '', + }, + ], + passwordValue: [ + '', + { + setSourcePasswordValue: (_, passwordValue) => passwordValue, + resetSourceState: () => '', + }, + ], + subdomainValue: [ + '', + { + setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, + resetSourceState: () => '', + }, + ], + indexPermissionsValue: [ + false, + { + setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, + resetSourceState: () => false, + }, + ], + githubOrganizations: [ + [], + { + setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, + resetSourceState: () => [], + }, + ], + selectedGithubOrganizationsMap: [ + {} as OrganizationsMap, + { + setSelectedGithubOrganizations: (state, option) => ({ + ...state, + ...{ [option]: !state[option] }, + }), + resetSourceState: () => ({}), + }, + ], + preContentSourceId: [ + '', + { + setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, + setPreContentSourceConfigData: () => '', + resetSourceState: () => '', + }, + ], + oauthConfigCompleted: [ + false, + { + setPreContentSourceConfigData: () => true, + }, + ], + sourceData: [getSourceData(props.serviceType) || null, {}], }), - saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ - search, - params, - isOrganization, + selectors: ({ selectors }) => ({ + selectedGithubOrganizations: [ + () => [selectors.selectedGithubOrganizationsMap], + (orgsMap) => keys(pickBy(orgsMap)), + ], }), - createContentSource: ( - serviceType: string, - successCallback: () => void, - errorCallback?: () => void - ) => ({ serviceType, successCallback, errorCallback }), - resetSourceState: () => true, - setButtonNotLoading: () => false, - setFirstStep: (addSourceProps) => ({ addSourceProps }), - }, - reducers: { - addSourceProps: [ - {} as AddSourceProps, - { - setAddSourceProps: (_, { addSourceProps }) => addSourceProps, - }, - ], - addSourceCurrentStep: [ - AddSourceSteps.ConfigIntroStep, - { - setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, - }, - ], - sourceConfigData: [ - {} as SourceConfigData, - { - setSourceConfigData: (_, sourceConfigData) => sourceConfigData, - }, - ], - sourceConnectData: [ - {} as SourceConnectData, - { - setSourceConnectData: (_, sourceConnectData) => sourceConnectData, - }, - ], - dataLoading: [ - true, - { - setSourceConfigData: () => false, - resetSourceState: () => false, - setPreContentSourceConfigData: () => false, - getSourceConfigData: () => true, - }, - ], - buttonLoading: [ - false, - { - setButtonNotLoading: () => false, - setSourceConnectData: () => false, - setSourceConfigData: () => false, - resetSourceState: () => false, - saveSourceConfig: () => true, - getSourceConnectData: () => true, - createContentSource: () => true, - }, - ], - sectionLoading: [ - true, - { - getPreContentSourceConfigData: () => true, - setPreContentSourceConfigData: () => false, - }, - ], - clientIdValue: [ - '', - { - setClientIdValue: (_, clientIdValue) => clientIdValue, - setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', - resetSourceState: () => '', - }, - ], - clientSecretValue: [ - '', - { - setClientSecretValue: (_, clientSecretValue) => clientSecretValue, - setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', - resetSourceState: () => '', - }, - ], - baseUrlValue: [ - '', - { - setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, - setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', - resetSourceState: () => '', - }, - ], - loginValue: [ - '', - { - setSourceLoginValue: (_, loginValue) => loginValue, - resetSourceState: () => '', - }, - ], - passwordValue: [ - '', - { - setSourcePasswordValue: (_, passwordValue) => passwordValue, - resetSourceState: () => '', - }, - ], - subdomainValue: [ - '', - { - setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, - resetSourceState: () => '', - }, - ], - indexPermissionsValue: [ - false, - { - setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, - resetSourceState: () => false, - }, - ], - currentServiceType: [ - '', - { - setPreContentSourceConfigData: (_, { serviceType }) => serviceType, - resetSourceState: () => '', - }, - ], - githubOrganizations: [ - [], - { - setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, - resetSourceState: () => [], + listeners: ({ actions, values, props }) => ({ + getSourceConfigData: async () => { + const { serviceType } = props; + // TODO: Once multi-config support for connectors is added, this request url will need to include an ID + const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConfigData(response); + actions.setFirstStep(); + } catch (e) { + flashAPIErrors(e); + } }, - ], - selectedGithubOrganizationsMap: [ - {} as OrganizationsMap, - { - setSelectedGithubOrganizations: (state, option) => ({ - ...state, - ...{ [option]: !state[option] }, - }), - resetSourceState: () => ({}), + getSourceConnectData: async ({ successCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; + + const route = isOrganization + ? `/internal/workplace_search/org/sources/${serviceType}/prepare` + : `/internal/workplace_search/account/sources/${serviceType}/prepare`; + + const indexPermissionsQuery = isOrganization + ? { index_permissions: indexPermissions } + : undefined; + + const query = subdomain + ? { + ...indexPermissionsQuery, + subdomain, + } + : { ...indexPermissionsQuery }; + + try { + const response = await HttpLogic.values.http.get(route, { + query, + }); + actions.setSourceConnectData(response); + successCallback(response.oauthUrl); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } }, - ], - preContentSourceId: [ - '', - { - setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, - setPreContentSourceConfigData: () => '', - resetSourceState: () => '', + getSourceReConnectData: async ({ sourceId }) => { + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` + : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConnectData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - oauthConfigCompleted: [ - false, - { - setPreContentSourceConfigData: () => true, + getPreContentSourceConfigData: async () => { + const { isOrganization } = AppLogic.values; + const { preContentSourceId } = values; + const route = isOrganization + ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` + : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setPreContentSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - }, - selectors: ({ selectors }) => ({ - selectedGithubOrganizations: [ - () => [selectors.selectedGithubOrganizationsMap], - (orgsMap) => keys(pickBy(orgsMap)), - ], - }), - listeners: ({ actions, values }) => ({ - initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = addSourceProps.sourceData; - actions.setAddSourceProps({ addSourceProps }); - actions.getSourceConfigData(serviceType, addSourceProps); - }, - getSourceConfigData: async ({ serviceType, addSourceProps }) => { - const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConfigData(response); - if (addSourceProps) { - actions.setFirstStep(addSourceProps); + saveSourceConfig: async ({ isUpdating, successCallback }) => { + clearFlashMessages(); + const { + sourceConfigData: { serviceType }, + baseUrlValue, + clientIdValue, + clientSecretValue, + sourceConfigData, + } = values; + + const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; + if ( + serviceType === 'external' && + externalConnectorUrl && + !isValidExternalUrl(externalConnectorUrl) + ) { + ExternalConnectorLogic.actions.setUrlValidation(false); + actions.setButtonNotLoading(); + return; } - } catch (e) { - flashAPIErrors(e); - } - }, - getSourceConnectData: async ({ serviceType, successCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; - - const route = isOrganization - ? `/internal/workplace_search/org/sources/${serviceType}/prepare` - : `/internal/workplace_search/account/sources/${serviceType}/prepare`; - - const indexPermissionsQuery = isOrganization - ? { index_permissions: indexPermissions } - : undefined; - - const query = subdomain - ? { - ...indexPermissionsQuery, - subdomain, + + const route = isUpdating + ? `/internal/workplace_search/org/settings/connectors/${serviceType}` + : '/internal/workplace_search/org/settings/connectors'; + + const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; + + const params = { + base_url: baseUrlValue || undefined, + client_id: clientIdValue || undefined, + client_secret: clientSecretValue || undefined, + service_type: serviceType, + private_key: sourceConfigData.configuredFields?.privateKey, + public_key: sourceConfigData.configuredFields?.publicKey, + consumer_key: sourceConfigData.configuredFields?.consumerKey, + external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, + external_connector_api_key: + (serviceType === 'external' && externalConnectorApiKey) || undefined, + }; + + try { + const response = await http(route, { + body: JSON.stringify(params), + }); + if (successCallback) successCallback(); + if (isUpdating) { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); } - : { ...indexPermissionsQuery }; - - try { - const response = await HttpLogic.values.http.get(route, { - query, - }); - actions.setSourceConnectData(response); - successCallback(response.oauthUrl); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - getSourceReConnectData: async ({ sourceId }) => { - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` - : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConnectData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - getPreContentSourceConfigData: async () => { - const { isOrganization } = AppLogic.values; - const { preContentSourceId } = values; - const route = isOrganization - ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` - : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setPreContentSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - saveSourceConfig: async ({ isUpdating, successCallback }) => { - clearFlashMessages(); - const { - sourceConfigData: { serviceType }, - baseUrlValue, - clientIdValue, - clientSecretValue, - sourceConfigData, - } = values; - - const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; - if ( - serviceType === 'external' && - externalConnectorUrl && - !isValidExternalUrl(externalConnectorUrl) - ) { - ExternalConnectorLogic.actions.setUrlValidation(false); - actions.setButtonNotLoading(); - return; - } - - const route = isUpdating - ? `/internal/workplace_search/org/settings/connectors/${serviceType}` - : '/internal/workplace_search/org/settings/connectors'; - - const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; - - const params = { - base_url: baseUrlValue || undefined, - client_id: clientIdValue || undefined, - client_secret: clientSecretValue || undefined, - service_type: serviceType, - private_key: sourceConfigData.configuredFields?.privateKey, - public_key: sourceConfigData.configuredFields?.publicKey, - consumer_key: sourceConfigData.configuredFields?.consumerKey, - external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, - external_connector_api_key: - (serviceType === 'external' && externalConnectorApiKey) || undefined, - }; - - try { - const response = await http(route, { - body: JSON.stringify(params), - }); - if (successCallback) successCallback(); - if (isUpdating) { - flashSuccessToast( - i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', - { - defaultMessage: 'Successfully updated configuration.', - } - ) - ); + actions.setSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); } - actions.setSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - saveSourceParams: async ({ search, params, isOrganization }) => { - const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const { setAddedSource } = SourcesLogic.actions; - const query = { ...params }; - const route = '/internal/workplace_search/sources/create'; - - /** + }, + saveSourceParams: async ({ search, params, isOrganization }) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { setAddedSource } = SourcesLogic.actions; + const query = { ...params }; + const route = '/internal/workplace_search/sources/create'; + + /** There is an extreme edge case where the user is trying to connect Github as source from ent-search, after configuring it in Kibana. When this happens, Github redirects the user from ent-search to Kibana with special error properties in the query params. In this case we need to redirect the user to the app home page and display the error message, and not persist the other query params to the server. */ - if (params.error_description) { - navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); - setErrorMessage( - isOrganization - ? params.error_description - : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) - ); - return; - } - - try { - const response = await http.get<{ - serviceName: string; - indexPermissions: boolean; - serviceType: string; - preContentSourceId: string; - hasConfigureStep: boolean; - }>(route, { query }); - const { serviceName, indexPermissions, serviceType, preContentSourceId, hasConfigureStep } = - response; - - // GitHub requires an intermediate configuration step, where we collect the repos to index. - if (hasConfigureStep && !values.oauthConfigCompleted) { - actions.setPreContentSourceId(preContentSourceId); - navigateToUrl( - getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + if (params.error_description) { + navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); + setErrorMessage( + isOrganization + ? params.error_description + : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) ); - } else { - setAddedSource(serviceName, indexPermissions, serviceType); + return; + } + + try { + const response = await http.get<{ + serviceName: string; + indexPermissions: boolean; + serviceType: string; + preContentSourceId: string; + hasConfigureStep: boolean; + }>(route, { query }); + const { + serviceName, + indexPermissions, + serviceType, + preContentSourceId, + hasConfigureStep, + } = response; + + // GitHub requires an intermediate configuration step, where we collect the repos to index. + if (hasConfigureStep && !values.oauthConfigCompleted) { + actions.setPreContentSourceId(preContentSourceId); + navigateToUrl( + getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + ); + } else { + setAddedSource(serviceName, indexPermissions, serviceType); + navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + } + } catch (e) { navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + flashAPIErrors(e); } - } catch (e) { - navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); - flashAPIErrors(e); - } - }, - setFirstStep: ({ addSourceProps }) => { - const firstStep = getFirstStep( - addSourceProps, - values.sourceConfigData, - SourcesLogic.values.externalConfigured - ); - actions.setAddSourceStep(firstStep); - }, - createContentSource: async ({ serviceType, successCallback, errorCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? '/internal/workplace_search/org/create_source' - : '/internal/workplace_search/account/create_source'; - - const { - selectedGithubOrganizations: githubOrganizations, - loginValue, - passwordValue, - indexPermissionsValue, - } = values; - - const params = { - service_type: serviceType, - login: loginValue || undefined, - password: passwordValue || undefined, - organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, - index_permissions: indexPermissionsValue || undefined, - } as { - [key: string]: string | string[] | undefined; - }; - - // Remove undefined values from params - Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); - - try { - await HttpLogic.values.http.post(route, { - body: JSON.stringify({ ...params }), - }); - successCallback(); - } catch (e) { - flashAPIErrors(e); - if (errorCallback) errorCallback(); - } finally { - actions.setButtonNotLoading(); - } - }, - }), -}); - -const getFirstStep = ( - props: AddSourceProps, - sourceConfigData: SourceConfigData, - externalConfigured: boolean -): AddSourceSteps => { + }, + setFirstStep: () => { + const firstStep = getFirstStep(values.sourceConfigData, props.initialStep); + actions.setAddSourceStep(firstStep); + }, + createContentSource: async ({ successCallback, errorCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { + selectedGithubOrganizations: githubOrganizations, + loginValue, + passwordValue, + indexPermissionsValue, + } = values; + + const params = { + service_type: serviceType, + login: loginValue || undefined, + password: passwordValue || undefined, + organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, + index_permissions: indexPermissionsValue || undefined, + } as { + [key: string]: string | string[] | undefined; + }; + + // Remove undefined values from params + Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); + + try { + await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + successCallback(); + } catch (e) { + flashAPIErrors(e); + if (errorCallback) errorCallback(); + } finally { + actions.setButtonNotLoading(); + } + }, + }), + } +); + +const getFirstStep = (sourceConfigData: SourceConfigData, initialStep?: string): AddSourceSteps => { const { - connect, - configure, - reAuthenticate, - sourceData: { serviceType, externalConnectorAvailable }, - } = props; - // We can land on this page from a choice page for multiple types of connectors - // If that's the case we want to skip the intro and configuration, if the external & internal connector have already been configured - const { configuredFields, configured } = sourceConfigData; - if (externalConnectorAvailable && configured && externalConfigured) + serviceType, + configured, + configuredFields: { clientId, clientSecret }, + } = sourceConfigData; + if (initialStep === 'connect') return AddSourceSteps.ConnectInstanceStep; + if (initialStep === 'configure') return AddSourceSteps.ConfigureOauthStep; + if (initialStep === 'reauthenticate') return AddSourceSteps.ReauthenticateStep; + if (serviceType !== 'external' && configured) return AddSourceSteps.ConnectInstanceStep; + + // TODO remove this once external/BYO connectors track `configured` properly + if (serviceType === 'external' && clientId && clientSecret) return AddSourceSteps.ConnectInstanceStep; - if (externalConnectorAvailable && !configured && externalConfigured) - return AddSourceSteps.SaveConfigStep; - if (serviceType === 'external') { - // external connectors can be partially configured, so we need to check which fields are filled - if (configuredFields?.clientId && configuredFields?.clientSecret) { - return AddSourceSteps.ConnectInstanceStep; - } - // Unconfigured external connectors have already shown the intro step before the choice page, so we don't want to show it again - return AddSourceSteps.SaveConfigStep; - } - if (connect) return AddSourceSteps.ConnectInstanceStep; - if (configure) return AddSourceSteps.ConfigureOauthStep; - if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; - return AddSourceSteps.ConfigIntroStep; + + return AddSourceSteps.SaveConfigStep; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index 06815ab3330f0..a44b5f54852c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(24); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(25); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 7dc9ad9ca0f60..9a2787d779070 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiText, EuiToolTip, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -43,8 +44,13 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { - const addPath = getAddPath(serviceType); + const getSourceCard = ({ + accountContextOnly, + baseServiceType, + name, + serviceType, + }: SourceDataItem) => { + const addPath = getAddPath(serviceType, baseServiceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -61,15 +67,30 @@ export const AvailableSourcesList: React.FC = ({ sour } )} > - - Connect - + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} + ); } else { return ( - - Connect + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} ); } @@ -79,7 +100,7 @@ export const AvailableSourcesList: React.FC = ({ sour <> - + {name} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx index 94821c0561cf4..0ed33a01d606f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; import { mount } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { staticSourceData } from '../../source_data'; import { ConfigurationChoice } from './configuration_choice'; describe('ConfigurationChoice', () => { - const { navigateToUrl } = mockKibanaValues; const props = { sourceData: staticSourceData[0], }; @@ -28,31 +27,23 @@ describe('ConfigurationChoice', () => { categories: [], }, }; + const mockActions = { + initializeSources: jest.fn(), + resetSourcesState: jest.fn(), + }; beforeEach(() => { - setMockValues(mockValues); jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); }); it('renders internal connector if available', () => { const wrapper = mount(); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to internal connector on internal connector click', () => { - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); - }); - it('should call prop function when provided on internal connector click', () => { - const advanceSpy = jest.fn(); - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).not.toHaveBeenCalled(); - expect(advanceSpy).toHaveBeenCalled(); + const internalConnectorCard = wrapper.find('[data-test-subj="InternalConnectorCard"]'); + expect(internalConnectorCard).toHaveLength(1); + expect(internalConnectorCard.find(EuiButtonTo).prop('to')).toEqual('/sources/add/box/'); }); it('renders external connector if available', () => { @@ -62,32 +53,36 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: true, + serviceType: 'share_point', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard).toHaveLength(1); + expect(externalConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point/external/connector_registration' + ); }); - it('should navigate to external connector on external connector click', () => { + + it('renders disabled message if external connector is available but user has already configured', () => { + setMockValues({ ...mockValues, externalConfigured: true }); + const wrapper = mount( ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard.prop('disabledMessage')).toBeDefined(); }); it('renders custom connector if available', () => { @@ -97,33 +92,16 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: false, - customConnectorAvailable: true, + serviceType: 'share_point_server', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to custom connector on custom connector click', () => { - const wrapper = mount( - + const customConnectorCard = wrapper.find('[data-test-subj="CustomConnectorCard"]'); + expect(customConnectorCard).toHaveLength(1); + expect(customConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point_server/custom' ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 8d8311d2a0a6f..7d5721d8547d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -5,92 +5,85 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { EuiButton, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../app_logic'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic } from './add_source_logic'; +import { hasCustomConnectorOption, hasExternalConnectorOption } from '../../source_data'; -interface ConfigurationChoiceProps { - sourceData: SourceDataItem; - goToInternalStep?: () => void; -} +import { SourcesLogic } from '../../sources_logic'; + +import { AddSourceHeader } from './add_source_header'; interface CardProps { title: string; description: string; buttonText: string; - onClick: () => void; + to: string; badgeLabel?: string; + disabledMessage?: string; +} + +const ConnectorCard: React.FC = ({ + title, + description, + buttonText, + to, + badgeLabel, + disabledMessage, +}: CardProps) => ( + + + {buttonText} + + } + /> + +); + +interface ConfigurationChoiceProps { + sourceData: SourceDataItem; } export const ConfigurationChoice: React.FC = ({ - sourceData: { - name, - serviceType, - externalConnectorAvailable, - internalConnectorAvailable, - customConnectorAvailable, - }, - goToInternalStep, + sourceData: { name, categories = [], serviceType }, }) => { + const externalConnectorAvailable = hasExternalConnectorOption(serviceType); + const customConnectorAvailable = hasCustomConnectorOption(serviceType); + const { isOrganization } = useValues(AppLogic); - const { sourceConfigData } = useValues(AddSourceLogic); - const { categories } = sourceConfigData; - const goToInternal = goToInternalStep - ? goToInternalStep - : () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, - isOrganization - )}/` - ); - const goToExternal = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, - isOrganization - )}/` - ); - const goToCustom = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, - isOrganization - )}/` - ); - - const ConnectorCard: React.FC = ({ - title, - description, - buttonText, - onClick, - badgeLabel, - }: CardProps) => ( - - - {buttonText} - - } - /> - - ); + + const { initializeSources, resetSourcesState } = useActions(SourcesLogic); + + const { externalConfigured } = useValues(SourcesLogic); + + useEffect(() => { + initializeSources(); + return resetSourcesState; + }, []); + + const internalTo = `${getSourcesPath(getAddPath(serviceType), isOrganization)}/`; + const externalTo = `${getSourcesPath( + getAddPath('external', serviceType), + isOrganization + )}/connector_registration`; + const customTo = `${getSourcesPath(getAddPath('custom', serviceType), isOrganization)}`; const internalConnectorProps: CardProps = { title: i18n.translate( @@ -118,7 +111,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Recommended', } ), - onClick: goToInternal, + to: internalTo, }; const externalConnectorProps: CardProps = { @@ -141,7 +134,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToExternal, + to: externalTo, badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { @@ -169,7 +162,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToCustom, + to: customTo, }; return ( @@ -177,9 +170,26 @@ export const ConfigurationChoice: React.FC = ({ - {internalConnectorAvailable && } - {externalConnectorAvailable && } - {customConnectorAvailable && } + + {externalConnectorAvailable && ( + + )} + {customConnectorAvailable && ( + + )} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx index b3ce53a0321dc..0f1beff70735c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx @@ -14,11 +14,10 @@ import { EuiText, EuiTitle } from '@elastic/eui'; import { ConfigurationIntro } from './configuration_intro'; describe('ConfigurationIntro', () => { - const advanceStep = jest.fn(); const props = { header:

Header

, name: 'foo', - advanceStep, + advanceStepTo: '', }; it('renderscontext', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 5c52537d4a738..e5da9f6e00316 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { EuiBadge, - EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -18,9 +17,12 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; + import connectionIllustration from '../../../../assets/connection_illustration.svg'; import { @@ -37,12 +39,12 @@ import { interface ConfigurationIntroProps { header: React.ReactNode; name: string; - advanceStep(): void; + advanceStepTo: string; } export const ConfigurationIntro: React.FC = ({ name, - advanceStep, + advanceStepTo, header, }) => ( <> @@ -144,11 +146,11 @@ export const ConfigurationIntro: React.FC = ({ - {i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', @@ -157,7 +159,7 @@ export const ConfigurationIntro: React.FC = ({ values: { name }, } )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx index 332456cae99ad..c776723377f44 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx @@ -22,7 +22,7 @@ describe('ConfigureOauth', () => { const onFormCreated = jest.fn(); const getPreContentSourceConfigData = jest.fn(); const setSelectedGithubOrganizations = jest.fn(); - const createContentSource = jest.fn((_, formSubmitSuccess, handleFormSubmitError) => { + const createContentSource = jest.fn((formSubmitSuccess, handleFormSubmitError) => { formSubmitSuccess(); handleFormSubmitError(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx index ce5a92a19e387..af50e8267da2f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx @@ -35,12 +35,8 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const { getPreContentSourceConfigData, setSelectedGithubOrganizations, createContentSource } = useActions(AddSourceLogic); - const { - currentServiceType, - githubOrganizations, - selectedGithubOrganizationsMap, - sectionLoading, - } = useValues(AddSourceLogic); + const { githubOrganizations, selectedGithubOrganizationsMap, sectionLoading } = + useValues(AddSourceLogic); const checkboxOptions = githubOrganizations.map((item) => ({ id: item, label: item })); @@ -54,7 +50,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const handleFormSubmit = (e: FormEvent) => { setFormLoading(true); e.preventDefault(); - createContentSource(currentServiceType, formSubmitSuccess, handleFormSubmitError); + createContentSource(formSubmitSuccess, handleFormSubmitError); }; const configfieldsForm = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index 5b23368289f1a..998a4c1d53b8a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiButtonEmpty } from '@elastic/eui'; + import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { ConfiguredSourcesList } from './configured_sources_list'; @@ -24,47 +26,38 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(20); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(21); expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(23); - }); - - it('does show connect button for a connected external source', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(EuiButtonEmptyTo)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(24); }); - it('does show connect button for an unconnected external source', () => { + it('shows connect button for an source with multiple connector options that routes to choice page', () => { const wrapper = shallow( ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/external/connect'); + expect(button.prop('to')).toEqual('/sources/add/share_point/choice'); }); - it('connect button for an unconnected source with multiple connector options routes to choice page', () => { + it('shows connect button for a source without multiple connector options that routes to add page', () => { const wrapper = shallow( { ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/share_point/'); + expect(button.prop('to')).toEqual('/sources/add/slack/'); }); - it('connect button for a source with multiple connector options routes to connect page for private sources', () => { + it('disabled when in organization mode and connector is account context only', () => { const wrapper = shallow( ); - const button = wrapper.find(EuiButtonEmptyTo); + const button = wrapper.find(EuiButtonEmpty); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/p/sources/add/share_point/connect'); + expect(button.prop('isDisabled')).toBe(true); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index bbec096ae07d8..820df302725b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -27,7 +27,8 @@ import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { hasMultipleConnectorOptions } from '../../../../utils'; + +import { hasMultipleConnectorOptions } from '../../source_data'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -72,7 +73,8 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( {sources.map((sourceData, i) => { - const { connected, accountContextOnly, name, serviceType, isBeta } = sourceData; + const { connected, accountContextOnly, name, serviceType, isBeta, baseServiceType } = + sourceData; return ( = ({ responsive={false} > - + @@ -128,7 +134,7 @@ export const ConfiguredSourcesList: React.FC = ({ {!connected diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index 3e850277c0b72..992bb561796fe 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -33,10 +33,10 @@ describe('ConnectInstance', () => { const setSourcePasswordValue = jest.fn(); const setSourceSubdomainValue = jest.fn(); const setSourceIndexPermissionsValue = jest.fn(); - const getSourceConnectData = jest.fn((_, redirectOauth) => { + const getSourceConnectData = jest.fn((redirectOauth) => { redirectOauth(); }); - const createContentSource = jest.fn((_, redirectFormCreated) => { + const createContentSource = jest.fn((redirectFormCreated) => { redirectFormCreated(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 352addd8176d8..0a4c1a9692e63 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -46,7 +46,6 @@ export const ConnectInstance: React.FC = ({ features, objTypes, name, - serviceType, needsPermissions, onFormCreated, header, @@ -74,8 +73,8 @@ export const ConnectInstance: React.FC = ({ const redirectOauth = (oauthUrl: string) => window.location.replace(oauthUrl); const redirectFormCreated = () => onFormCreated(name); - const onOauthFormSubmit = () => getSourceConnectData(serviceType, redirectOauth); - const onCredentialsFormSubmit = () => createContentSource(serviceType, redirectFormCreated); + const onOauthFormSubmit = () => getSourceConnectData(redirectOauth); + const onCredentialsFormSubmit = () => createContentSource(redirectFormCreated); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx index edfb2897fce15..7a80c9d6980b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx @@ -39,7 +39,7 @@ import { SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE_DESCRIPTION, } from './constants'; -interface ConnectInstanceProps { +interface SourceFeatureProps { features?: Features; objTypes?: string[]; name: string; @@ -47,7 +47,7 @@ interface ConnectInstanceProps { type IncludedFeatureIds = Exclude; -export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { +export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { isOrganization } = useValues(AppLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx index afacfd0ccbbf9..017a9eb5b5dd0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx @@ -24,14 +24,6 @@ const customSource = { name: 'name', }; -const preconfiguredSourceData = { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, -}; const mockValues = { sourceData: staticCustomSourceData, }; @@ -44,9 +36,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - wrapper = shallow( - - ); + wrapper = shallow(); }); it('contains a source identifier', () => { @@ -69,7 +59,7 @@ describe('CustomSourceDeployment', () => { }); wrapper = shallow( - + ); }); @@ -86,9 +76,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(EuiPanel).prop('paddingSize')).toEqual('m'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx index 7d34783e998a7..8910a8acd0c5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx @@ -14,17 +14,30 @@ import { EuiLinkTo } from '../../../../shared/react_router_helpers'; import { API_KEY_LABEL } from '../../../constants'; import { API_KEYS_PATH } from '../../../routes'; -import { ContentSource, CustomSource, SourceDataItem } from '../../../types'; +import { ContentSource, CustomSource } from '../../../types'; + +import { getSourceData } from '../source_data'; import { SourceIdentifier } from './source_identifier'; interface Props { source: ContentSource | CustomSource; - sourceData: SourceDataItem; + baseServiceType?: string; small?: boolean; } -export const CustomSourceDeployment: React.FC = ({ source, sourceData, small = false }) => { +export const CustomSourceDeployment: React.FC = ({ + source, + baseServiceType, + small = false, +}) => { const { name, id } = source; + + const sourceData = getSourceData('custom', baseServiceType); + + if (!sourceData) { + return null; + } + const { configuration: { documentationUrl, githubRepository }, } = sourceData; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index 9af4eae693d7c..ae6e516ef7d4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -16,8 +16,6 @@ import { EuiCallOut, EuiConfirmModal, EuiEmptyPrompt, EuiTable } from '@elastic/ import { ComponentLoader } from '../../../components/shared/component_loader'; -import * as SourceData from '../source_data'; - import { CustomSourceDeployment } from './custom_source_deployment'; import { Overview } from './overview'; @@ -144,33 +142,6 @@ describe('Overview', () => { expect(initializeSourceSynchronization).toHaveBeenCalled(); }); - it('uses a base service type if one is provided', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: { - ...fullContentSources[0], - baseServiceType: 'share_point_server', - }, - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('share_point_server'); - }); - - it('defaults to the regular service tye', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: fullContentSources[0], - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('custom'); - }); - describe('custom sources', () => { it('includes deployment instructions', () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 698dc7a60eea4..ac31ee8314fc8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -81,7 +81,6 @@ import { SOURCE_SYNC_CONFIRM_TITLE, SOURCE_SYNC_CONFIRM_MESSAGE, } from '../constants'; -import { getSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { CustomSourceDeployment } from './custom_source_deployment'; @@ -106,12 +105,10 @@ export const Overview: React.FC = () => { isFederatedSource, isIndexedSource, name, + serviceType, + baseServiceType, } = contentSource; - const serviceType = contentSource.baseServiceType || contentSource.serviceType; - - const sourceData = getSourceData(serviceType); - const [isSyncing, setIsSyncing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); const closeModal = () => setIsModalVisible(false); @@ -431,7 +428,7 @@ export const Overview: React.FC = () => { - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d660b4499e210..f872648fc101d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -88,7 +88,7 @@ export const SourceSettings: React.FC = () => { const { isOrganization } = useValues(AppLogic); useEffect(() => { - getSourceConfigData(serviceType); + getSourceConfigData(); }, []); const isGithubApp = diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 282de2590df7f..0088e80066a02 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -17,9 +17,10 @@ import { } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticExternalSourceData: SourceDataItem = { +// TODO remove Sharepoint-specific content after BYO connector support +export const staticGenericExternalSourceData: SourceDataItem = { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, + categories: [], serviceType: 'external', configuration: { isPublicKey: false, @@ -40,16 +41,12 @@ export const staticExternalSourceData: SourceDataItem = { platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, isBeta: true, }; export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, serviceType: 'box', configuration: { isPublicKey: false, @@ -74,11 +71,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, - iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', configuration: { isPublicKey: false, @@ -108,11 +103,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, - iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', configuration: { isPublicKey: true, @@ -140,11 +133,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, - iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', configuration: { isPublicKey: false, @@ -169,11 +160,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, - iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', configuration: { isPublicKey: false, @@ -205,11 +194,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, - iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', configuration: { isPublicKey: false, @@ -247,11 +234,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, - iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', configuration: { isPublicKey: false, @@ -265,11 +250,9 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GOOGLE_DRIVE, - iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', configuration: { isPublicKey: false, @@ -298,11 +281,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, - iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', configuration: { isPublicKey: false, @@ -334,11 +315,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, - iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', configuration: { isPublicKey: true, @@ -369,13 +348,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.NETWORK_DRVE, - iconName: SOURCE_NAMES.NETWORK_DRVE, categories: [SOURCE_CATEGORIES.STORAGE], - serviceType: 'network_drive', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'network_drive', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -385,12 +363,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-network-drive-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, - iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', configuration: { isPublicKey: false, @@ -415,17 +390,16 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.OUTLOOK, - iconName: SOURCE_NAMES.OUTLOOK, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'outlook', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'outlook', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -435,12 +409,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-outlook-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, - iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', configuration: { isPublicKey: false, @@ -472,11 +443,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE_SANDBOX, - iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', configuration: { isPublicKey: false, @@ -508,11 +477,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, - iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', configuration: { isPublicKey: false, @@ -541,11 +508,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', configuration: { isPublicKey: false, @@ -570,13 +535,39 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: true, }, - staticExternalSourceData, + { + name: SOURCE_NAMES.SHAREPOINT, + categories: [], + serviceType: 'external', + baseServiceType: 'share_point', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.ALL_STORED_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + isBeta: true, + }, { name: SOURCE_NAMES.SHAREPOINT_SERVER, - iconName: SOURCE_NAMES.SHAREPOINT_SERVER, categories: [ SOURCE_CATEGORIES.FILE_SHARING, SOURCE_CATEGORIES.STORAGE, @@ -584,7 +575,8 @@ export const staticSourceData: SourceDataItem[] = [ SOURCE_CATEGORIES.MICROSOFT, SOURCE_CATEGORIES.OFFICE_365, ], - serviceType: 'share_point_server', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'share_point_server', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -594,12 +586,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, - iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', configuration: { isPublicKey: false, @@ -617,17 +606,16 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.TEAMS, - iconName: SOURCE_NAMES.TEAMS, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'teams', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'teams', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -637,12 +625,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-teams-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ZENDESK, - iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', configuration: { isPublicKey: false, @@ -667,13 +652,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ZOOM, - iconName: SOURCE_NAMES.ZOOM, categories: [SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY], - serviceType: 'zoom', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'zoom', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -683,14 +667,12 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-zoom-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, + staticGenericExternalSourceData, ]; export const staticCustomSourceData: SourceDataItem = { name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, categories: ['API', 'Custom'], serviceType: 'custom', configuration: { @@ -701,12 +683,26 @@ export const staticCustomSourceData: SourceDataItem = { applicationPortalUrl: '', }, accountContextOnly: false, - customConnectorAvailable: true, }; -export const getSourceData = (serviceType: string): SourceDataItem => { - return ( - staticSourceData.find((staticSource) => staticSource.serviceType === serviceType) || - staticCustomSourceData +export const getSourceData = ( + serviceType: string, + baseServiceType?: string +): SourceDataItem | undefined => { + if (serviceType === 'custom' && typeof baseServiceType === 'undefined') { + return staticCustomSourceData; + } + return staticSourceData.find( + (staticSource) => + staticSource.serviceType === serviceType && staticSource.baseServiceType === baseServiceType ); }; + +export const hasExternalConnectorOption = (serviceType: string): boolean => + !!getSourceData('external', serviceType); + +export const hasCustomConnectorOption = (serviceType: string): boolean => + !!getSourceData('custom', serviceType); + +export const hasMultipleConnectorOptions = (serviceType: string): boolean => + hasExternalConnectorOption(serviceType) || hasCustomConnectorOption(serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index 0f113ad402f28..0fdb827f6011d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -23,7 +23,12 @@ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; import { staticSourceData } from './source_data'; -import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; +import { + SourcesLogic, + fetchSourceStatuses, + POLLING_INTERVAL, + mergeServerAndStaticData, +} from './sources_logic'; describe('SourcesLogic', () => { const { http } = mockHttpValues; @@ -37,8 +42,14 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), - availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), + sourceData: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), + availableSources: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -322,7 +333,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(18); + expect(SourcesLogic.values.availableSources).toHaveLength(19); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); it('externalConfigured is set to true if external is configured', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 868831ab7c7fb..0f61ee580f677 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -51,7 +51,7 @@ export interface IPermissionsModalProps { additionalConfiguration: boolean; } -type CombinedDataItem = SourceDataItem & { connected: boolean }; +type CombinedDataItem = SourceDataItem & Partial & { connected: boolean }; export interface ISourcesValues { contentSources: ContentSourceDetails[]; @@ -145,17 +145,17 @@ export const SourcesLogic = kea>( selectors: ({ selectors }) => ({ availableSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => !configured)), ], configuredSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => configured)), ], externalConfigured: [ () => [selectors.configuredSources], - (configuredSources: SourceDataItem[]) => + (configuredSources: CombinedDataItem[]) => !!configuredSources.find((item) => item.serviceType === 'external'), ], sourceData: [ @@ -312,9 +312,12 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ): CombinedDataItem[] => { const unsortedData = staticData.map((staticItem) => { - const serverItem = serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); + const serverItem = staticItem.baseServiceType + ? undefined // static items with base service types will never have matching external connectors, BE doesn't pass us a baseServiceType + : serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); const connectedSource = contentSources.find( - ({ serviceType }) => serviceType === staticItem.serviceType + ({ baseServiceType, serviceType }) => + serviceType === staticItem.serviceType && baseServiceType === staticItem.baseServiceType ); return { ...staticItem, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index 0fa263beab539..07baa82a5cdb0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -10,11 +10,11 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; +import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../routes'; import { SourcesRouter } from './sources_router'; @@ -34,19 +34,13 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 103; const wrapper = shallow(); - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(TOTAL_ROUTES); - }); - - it('redirects when nonplatinum license and accountOnly context', () => { - setMockValues({ ...mockValues, hasPlatinumLicense: false }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find('[data-test-subj="ConnectorIntroRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConnectorChoiceRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ExternalConnectorConfigRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="AddCustomSourceRoute"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="AddSourceRoute"]')).toHaveLength(1); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 19af955f8780c..4d4ec077213a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,7 +11,6 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, @@ -24,17 +23,15 @@ import { SOURCES_PATH, getSourcesPath, getAddPath, - ADD_CUSTOM_PATH, } from '../../routes'; -import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; -import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { AddSourceChoice } from './components/add_source/add_source_choice'; +import { AddSourceIntro } from './components/add_source/add_source_intro'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -42,7 +39,6 @@ import './sources.scss'; export const SourcesRouter: React.FC = () => { const { pathname } = useLocation() as Location; - const { hasPlatinumLicense } = useValues(LicensingLogic); const { resetSourcesState } = useActions(SourcesLogic); const { account: { canCreatePrivateSources }, @@ -82,119 +78,51 @@ export const SourcesRouter: React.FC = () => { - {sources.map((sourceData, i) => { - const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; - const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; - const defaultOption = internalConnectorAvailable - ? 'internal' - : externalConnectorAvailable - ? 'external' - : 'custom'; - const showChoice = defaultOption !== 'internal' && hasMultipleConnectorOptions(sourceData); - return ( - - {showChoice ? ( - - ) : ( - - )} - - ); - })} - - + + + + + + + + + + + + + + + + + - {sources - .filter((sourceData) => sourceData.internalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.externalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.customConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => { - if (sourceData.configuration.needsConfiguration) - return ( - - - - ); - })} {canCreatePrivateSources ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 8399df946ea83..bc457ca0a1c00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -8,6 +8,7 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +19,6 @@ import { EuiCallOut, EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; - import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -30,10 +29,11 @@ describe('SourceConfig', () => { beforeEach(() => { setMockValues({ sourceConfigData, dataLoading: false }); setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -43,15 +43,23 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -63,7 +71,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -75,7 +83,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -87,9 +95,8 @@ describe('SourceConfig', () => { }); it('shows feedback link for external sources', () => { - const wrapper = shallow( - - ); + mockUseParams.mockReturnValue({ serviceType: 'external' }); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 6973732fa6727..76ed6023109d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -7,6 +7,8 @@ import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { @@ -21,29 +23,34 @@ import { i18n } from '@kbn/i18n'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { NAV, REMOVE_BUTTON, CANCEL_BUTTON } from '../../../constants'; -import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { getSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; -interface SourceConfigProps { - sourceData: SourceDataItem; -} - -export const SourceConfig: React.FC = ({ sourceData }) => { +export const SourceConfig: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = sourceData; + const addSourceLogic = AddSourceLogic({ serviceType }); const { deleteSourceConfig } = useActions(SettingsLogic); - const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); + const { saveSourceConfig, getSourceConfigData, resetSourceState } = useActions(addSourceLogic); const { sourceConfigData: { name, categories }, dataLoading, - } = useValues(AddSourceLogic); + } = useValues(addSourceLogic); + const sourceData = getSourceData(serviceType); useEffect(() => { - getSourceConfigData(serviceType); - }, []); + getSourceConfigData(); + return resetSourceState; + }, [serviceType]); + + if (!sourceData) { + return null; + } + + const { configuration } = sourceData; const hideConfirmModal = () => setConfirmModalVisibility(false); const showConfirmModal = () => setConfirmModalVisibility(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx index 123167f0ad1d0..604c155215724 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx @@ -10,12 +10,10 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { staticSourceData } from '../content_sources/source_data'; - import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; import { OauthApplication } from './components/oauth_application'; @@ -24,9 +22,6 @@ import { SettingsRouter } from './settings_router'; describe('SettingsRouter', () => { const initializeSettings = jest.fn(); - const NUM_SOURCES = staticSourceData.length; - // Should be 4 routes other than the sources listed: Connectors, Customize, & OauthApplication, & a redirect - const NUM_ROUTES = NUM_SOURCES + 4; beforeEach(() => { setMockActions({ initializeSettings }); @@ -36,11 +31,10 @@ describe('SettingsRouter', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); expect(wrapper.find(Redirect)).toHaveLength(1); expect(wrapper.find(Connectors)).toHaveLength(1); expect(wrapper.find(Customize)).toHaveLength(1); expect(wrapper.find(OauthApplication)).toHaveLength(1); - expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + expect(wrapper.find(SourceConfig)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index 7c5e501d6a2a1..fc250bbfbf4e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -16,7 +16,6 @@ import { ORG_SETTINGS_OAUTH_APPLICATION_PATH, getEditPath, } from '../../routes'; -import { staticSourceData } from '../content_sources/source_data'; import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; @@ -42,11 +41,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map((sourceData, i) => ( - - - - ))} + + + diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index f1f849bd3b17b..a9295ada0dd85 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -143,6 +143,15 @@ describe('EventLogger', () => { expect(nanosToMillis(duration)).toBeCloseTo(timeStopValue - timeStartValue); }); + test('can set specific start time in startTiming', () => { + const event: IEvent = {}; + eventLogger.startTiming(event, new Date('2020-01-01T02:00:00.000Z')); + + const timeStart = event.event!.start!; + expect(timeStart).toBeTruthy(); + expect(timeStart).toEqual('2020-01-01T02:00:00.000Z'); + }); + test('timing method endTiming() method works when startTiming() is not called', async () => { const event: IEvent = {}; eventLogger.stopTiming(event); diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 67d9dc61f4e18..14cde6c191fa3 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -47,11 +47,12 @@ export class EventLogger implements IEventLogger { this.systemLogger = ctorParams.systemLogger; } - startTiming(event: IEvent): void { + startTiming(event: IEvent, startTime?: Date): void { if (event == null) return; event.event = event.event || {}; - event.event.start = new Date().toISOString(); + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); } stopTiming(event: IEvent): void { diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 1336245741bd6..3291f162c09df 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -66,7 +66,7 @@ export interface IEventLogClient { export interface IEventLogger { logEvent(properties: IEvent): void; - startTiming(event: IEvent): void; + startTiming(event: IEvent, startTime?: Date): void; stopTiming(event: IEvent): void; } diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 018f591fef79c..d41a08b8b4755 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -11,8 +11,6 @@ import type { AGENT_TYPE_TEMPORARY, } from '../../constants'; -import type { FullAgentPolicy } from './agent_policy'; - export type AgentType = | typeof AGENT_TYPE_EPHEMERAL | typeof AGENT_TYPE_PERMANENT @@ -41,7 +39,11 @@ export type AgentActionType = export interface NewAgentAction { type: AgentActionType; data?: any; + ack_data?: any; sent_at?: string; + agents: string[]; + created_at?: string; + id?: string; } export interface AgentAction extends NewAgentAction { @@ -49,41 +51,10 @@ export interface AgentAction extends NewAgentAction { data?: any; sent_at?: string; id: string; - agent_id: string; - created_at: string; - ack_data?: any; -} - -export interface AgentPolicyAction extends NewAgentAction { - id: string; - type: AgentActionType; - data: { - policy: FullAgentPolicy; - }; - policy_id: string; - policy_revision: number; created_at: string; ack_data?: any; } -interface CommonAgentActionSOAttributes { - type: AgentActionType; - sent_at?: string; - timestamp?: string; - created_at: string; - data?: string; - ack_data?: string; -} - -export type AgentActionSOAttributes = CommonAgentActionSOAttributes & { - agent_id: string; -}; -export type AgentPolicyActionSOAttributes = CommonAgentActionSOAttributes & { - policy_id: string; - policy_revision: number; -}; -export type BaseAgentActionSOAttributes = AgentActionSOAttributes | AgentPolicyActionSOAttributes; - export interface AgentMetadata { [x: string]: any; } @@ -104,6 +75,7 @@ interface AgentBase { last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; + tags?: string[]; } export interface Agent extends AgentBase { @@ -216,6 +188,10 @@ export interface FleetServerAgent { * The last acknowledged action sequence number for the Elastic Agent */ action_seq_no?: number; + /** + * A list of tags used for organizing/filtering agents + */ + tags?: string[]; } /** * An Elastic Agent metadata @@ -268,6 +244,17 @@ export interface FleetServerAgentAction { * The Agent IDs the action is intended for. No support for json.RawMessage with the current generator. Could be useful to lazy parse the agent ids */ agents?: string[]; + + /** + * Date when the agent should execute that agent. This field could be altered by Fleet server for progressive rollout of the action. + */ + start_time?: string; + + /** + * Minimun execution duration in seconds, used for progressive rollout of the action. + */ + minimum_execution_duration?: number; + /** * The opaque payload. */ 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 40570bc599053..aa256db95634a 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -34,7 +34,7 @@ export interface GetOneAgentResponse { export interface PostNewAgentActionRequest { body: { - action: NewAgentAction; + action: Omit; }; params: { agentId: string; diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index b871f6d4e690b..eb8b01d831cd5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -8,7 +8,7 @@ import type { FunctionComponent } from 'react'; import React, { memo, useEffect, useState } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel } from '@elastic/eui'; +import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,7 +27,7 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { PackageInstallProvider } from '../integrations/hooks'; -import { useAuthz } from './hooks'; +import { useAuthz, useFlyoutContext } from './hooks'; import { ConfigContext, @@ -38,8 +38,15 @@ import { useBreadcrumbs, useStartServices, UIExtensionsContext, + FlyoutContextProvider, } from './hooks'; -import { Error, Loading, FleetSetupLoading } from './components'; +import { + Error, + Loading, + FleetSetupLoading, + AgentEnrollmentFlyout, + FleetServerFlyout, +} from './components'; import type { UIExtensionsStorage } from './types'; import { FLEET_ROUTING_PATHS } from './constants'; @@ -251,7 +258,7 @@ export const FleetAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - {children} + {children} @@ -295,6 +302,8 @@ const FleetTopNav = memo( export const AppRoutes = memo( ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -343,6 +352,22 @@ export const AppRoutes = memo( }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx index 87b4a1bda7ff7..5c5f87b19f977 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx @@ -57,6 +57,7 @@ export const AdvancedTab: React.FunctionComponent = () => { serviceToken, fleetServerHost: fleetServerHostForm.fleetServerHost, fleetServerPolicyId, + deploymentMode, disabled: !Boolean(serviceToken), }), getConfirmFleetServerConnectionStep({ isFleetServerReady, disabled: !Boolean(serviceToken) }), diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx index cf8abc2fe9e16..758a34113efcd 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx @@ -29,6 +29,7 @@ export const QuickStartTab: React.FunctionComponent = () => { fleetServerHost: quickStartCreateForm.fleetServerHost, fleetServerPolicyId: quickStartCreateForm.fleetServerPolicyId, serviceToken: quickStartCreateForm.serviceToken, + deploymentMode: 'quickstart', disabled: quickStartCreateForm.status !== 'success', }), getConfirmFleetServerConnectionStep({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx index e64e23f039f89..70753e37f8e8a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx @@ -104,8 +104,13 @@ export const AddFleetServerHostStepContent = ({ /> - - + + = ({ isFleetServerReady }) => { - const addAgentFlyout = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); return isFleetServerReady ? ( <> @@ -53,7 +53,7 @@ const ConfirmFleetServerConnectionStepContent: React.FunctionComponent<{ - + ), }; @@ -51,7 +56,8 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ serviceToken?: string; fleetServerHost?: string; fleetServerPolicyId?: string; -}> = ({ serviceToken, fleetServerHost, fleetServerPolicyId }) => { + deploymentMode: DeploymentMode; +}> = ({ serviceToken, fleetServerHost, fleetServerPolicyId, deploymentMode }) => { const kibanaVersion = useKibanaVersion(); const { output } = useDefaultOutput(); @@ -63,7 +69,7 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ serviceToken ?? '', fleetServerPolicyId, fleetServerHost, - false, + deploymentMode === 'production', output?.ca_trusted_fingerprint, kibanaVersion ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts index 89a246c5c6265..12c1af65f9555 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts @@ -17,9 +17,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -34,9 +34,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -51,9 +51,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" @@ -68,9 +68,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -85,9 +85,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -106,9 +106,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -127,9 +127,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -146,9 +146,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -165,9 +165,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1 \` @@ -184,9 +184,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -203,9 +203,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -226,9 +226,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install--url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -251,9 +251,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -276,9 +276,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1 \` @@ -301,9 +301,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -326,9 +326,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts index 525af7cf95103..ed38478c3a3ee 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts @@ -67,12 +67,12 @@ export function getInstallCommandForPlatform( `wget ${artifact.fullUrl} -OutFile ${artifact.filename}`, `Expand-Archive .\\${artifact.filename}`, `cd ${artifact.unpackedDir}`, - ].join(` ${newLineSeparator}`) + ].join(`\n`) : [ `curl -L -O ${artifact.fullUrl}`, `tar xzvf ${artifact.filename}`, `cd ${artifact.unpackedDir}`, - ].join(` ${newLineSeparator}`); + ].join(`\n`); const commandArguments = []; @@ -108,11 +108,11 @@ export function getInstallCommandForPlatform( }, ''); const commands = { - linux: `${downloadCommand} ${newLineSeparator}sudo ./elastic-agent install${commandArgumentsStr}`, - mac: `${downloadCommand} ${newLineSeparator}sudo ./elastic-agent install ${commandArgumentsStr}`, - windows: `${downloadCommand}${newLineSeparator}.\\elastic-agent.exe install ${commandArgumentsStr}`, - deb: `${downloadCommand} ${newLineSeparator}sudo elastic-agent enroll ${commandArgumentsStr}`, - rpm: `${downloadCommand} ${newLineSeparator}sudo elastic-agent enroll ${commandArgumentsStr}`, + linux: `${downloadCommand}\nsudo ./elastic-agent install${commandArgumentsStr}`, + mac: `${downloadCommand}\nsudo ./elastic-agent install ${commandArgumentsStr}`, + windows: `${downloadCommand}\n.\\elastic-agent.exe install ${commandArgumentsStr}`, + deb: `${downloadCommand}\nsudo elastic-agent enroll ${commandArgumentsStr}`, + rpm: `${downloadCommand}\nsudo elastic-agent enroll ${commandArgumentsStr}`, }; return commands[platform]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index a511c2dc9f3da..60a97845312e8 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -70,6 +70,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onSelectedStatusChange: (selectedStatus: string[]) => void; showUpgradeable: boolean; onShowUpgradeableChange: (showUpgradeable: boolean) => void; + tags: string[]; + selectedTags: string[]; + onSelectedTagsChange: (selectedTags: string[]) => void; totalAgents: number; totalInactiveAgents: number; selectionMode: SelectionMode; @@ -87,6 +90,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onSelectedStatusChange, showUpgradeable, onShowUpgradeableChange, + tags, + selectedTags, + onSelectedTagsChange, totalAgents, totalInactiveAgents, selectionMode, @@ -100,7 +106,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); // Status for filtering - const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); + const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(false); + + const [isTagsFilterOpen, setIsTagsFilterOpen] = useState(false); // Add a agent policy id to current search const addAgentPolicyFilter = (policyId: string) => { @@ -114,6 +122,14 @@ export const SearchAndFilterBar: React.FunctionComponent<{ ); }; + const addTagsFilter = (tag: string) => { + onSelectedTagsChange([...selectedTags, tag]); + }; + + const removeTagsFilter = (tag: string) => { + onSelectedTagsChange(selectedTags.filter((t) => t !== tag)); + }; + return ( <> {isEnrollmentFlyoutOpen ? ( @@ -146,7 +162,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ button={ setIsStatutsFilterOpen(!isStatusFilterOpen)} + onClick={() => setIsStatusFilterOpen(!isStatusFilterOpen)} isSelected={isStatusFilterOpen} hasActiveFilters={selectedStatus.length > 0} disabled={agentPolicies.length === 0} @@ -159,7 +175,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ } isOpen={isStatusFilterOpen} - closePopover={() => setIsStatutsFilterOpen(false)} + closePopover={() => setIsStatusFilterOpen(false)} panelPaddingSize="none" >
@@ -180,6 +196,46 @@ export const SearchAndFilterBar: React.FunctionComponent<{ ))}
+ setIsTagsFilterOpen(!isTagsFilterOpen)} + isSelected={isTagsFilterOpen} + hasActiveFilters={selectedTags.length > 0} + numFilters={selectedTags.length} + disabled={tags.length === 0} + data-test-subj="agentList.tagsFilter" + > + + + } + isOpen={isTagsFilterOpen} + closePopover={() => setIsTagsFilterOpen(false)} + panelPaddingSize="none" + > +
+ {tags.map((tag, index) => ( + { + if (selectedTags.includes(tag)) { + removeTagsFilter(tag); + } else { + addTagsFilter(tag); + } + }} + > + {tag} + + ))} +
+
{ + describe('when list is short', () => { + it('renders a comma-separated list of tags', () => { + const tags = ['tag1', 'tag2']; + render(); + + expect(screen.getByTestId('agentTags')).toHaveTextContent('tag1, tag2'); + }); + }); + + describe('when list is long', () => { + it('renders a truncated list of tags with full list displayed in tooltip on hover', async () => { + const tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; + render(); + + const tagsNode = screen.getByTestId('agentTags'); + + expect(tagsNode).toHaveTextContent('tag1, tag2, tag3 + 2 more'); + + fireEvent.mouseEnter(tagsNode); + await waitFor(() => { + screen.getByTestId('agentTagsTooltip'); + }); + + expect(screen.getByTestId('agentTagsTooltip')).toHaveTextContent( + 'tag1, tag2, tag3, tag4, tag5' + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx new file mode 100644 index 0000000000000..7650b0d942180 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { take } from 'lodash'; +import React from 'react'; + +interface Props { + tags: string[]; +} + +const MAX_TAGS_TO_DISPLAY = 3; + +export const Tags: React.FunctionComponent = ({ tags }) => { + return ( + <> + {tags.length > MAX_TAGS_TO_DISPLAY ? ( + <> + {tags.join(', ')}}> + + {take(tags, 3).join(', ')} + {tags.length - MAX_TAGS_TO_DISPLAY} more + + + + ) : ( + {tags.join(', ')} + )} + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 5776a163fd6a3..660a06911a5f0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useMemo, useCallback, useRef, useEffect, useContext } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { EuiBasicTable, EuiFlexGroup, @@ -31,6 +31,7 @@ import { useBreadcrumbs, useKibanaVersion, useStartServices, + useFlyoutContext, } from '../../../hooks'; import { AgentEnrollmentFlyout, AgentPolicySummaryLine } from '../../../components'; import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; @@ -45,11 +46,10 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; -import { agentFlyoutContext } from '..'; - import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; +import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; @@ -98,14 +98,21 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Status for filtering const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + const isUsingFilter = - search.trim() || selectedAgentPolicies.length || selectedStatus.length || showUpgradeable; + search.trim() || + selectedAgentPolicies.length || + selectedStatus.length || + selectedTags.length || + showUpgradeable; const clearFilters = useCallback(() => { setDraftKuery(''); setSearch(''); setSelectedAgentPolicies([]); setSelectedStatus([]); + setSelectedTags([]); setShowUpgradeable(false); }, [setSearch, setDraftKuery, setSelectedAgentPolicies, setSelectedStatus, setShowUpgradeable]); @@ -117,7 +124,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { isOpen: false, }); - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); @@ -135,6 +142,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .map((agentPolicy) => `"${agentPolicy}"`) .join(' or ')})`; } + + if (selectedTags.length) { + kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags + .map((tag) => `"${tag}"`) + .join(' or ')})`; + } + if (selectedStatus.length) { const kueryStatus = selectedStatus .map((status) => { @@ -164,7 +178,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { } return kueryBuilder; - }, [selectedStatus, selectedAgentPolicies, search]); + }, [search, selectedAgentPolicies, selectedTags, selectedStatus]); const showInactive = useMemo(() => { return selectedStatus.includes('inactive'); @@ -174,6 +188,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const [agentsStatus, setAgentsStatus] = useState< { [key in SimplifiedAgentStatus]: number } | undefined >(); + const [allTags, setAllTags] = useState(); const [isLoading, setIsLoading] = useState(false); const [totalAgents, setTotalAgents] = useState(0); const [totalInactiveAgents, setTotalInactiveAgents] = useState(0); @@ -224,6 +239,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { inactive: agentsRequest.data.totalInactive, }); + // Only set tags on the first request - we don't want the list of tags to update based + // on the returned set of agents from the API + if (allTags === undefined) { + const newAllTags = Array.from( + new Set(agentsRequest.data.items.flatMap((agent) => agent.tags ?? [])) + ); + + setAllTags(newAllTags); + } + setAgents(agentsRequest.data.items); setTotalAgents(agentsRequest.data.total); setTotalInactiveAgents(agentsRequest.data.totalInactive); @@ -237,7 +262,15 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { setIsLoading(false); } fetchDataAsync(); - }, [pagination, kuery, showInactive, showUpgradeable, notifications.toasts]); + }, [ + pagination.currentPage, + pagination.pageSize, + kuery, + showInactive, + showUpgradeable, + allTags, + notifications.toasts, + ]); // Send request to get agent list and status useEffect(() => { @@ -296,7 +329,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Fleet server unhealthy status const { isUnhealthy: isFleetServerUnhealthy } = useFleetServerUnhealthy(); const onClickAddFleetServer = useCallback(() => { - flyoutContext?.openFleetServerFlyout(); + flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); const columns = [ @@ -319,6 +352,14 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }), render: (active: boolean, agent: any) => , }, + { + field: 'tags', + width: '240px', + name: i18n.translate('xpack.fleet.agentList.tagsColumnTitle', { + defaultMessage: 'Tags', + }), + render: (tags: string[] = [], agent: any) => , + }, { field: 'policy_id', name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', { @@ -481,6 +522,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { onSelectedStatusChange={setSelectedStatus} showUpgradeable={showUpgradeable} onShowUpgradeableChange={setShowUpgradeable} + tags={allTags ?? []} + selectedTags={selectedTags} + onSelectedTagsChange={setSelectedTags} totalAgents={totalAgents} totalInactiveAgents={totalInactiveAgents} selectionMode={selectionMode} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx index 86990d84d5130..409a259f934dd 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiButton, EuiButtonEmpty, @@ -17,14 +17,12 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useStartServices } from '../../../../hooks'; - -import { agentFlyoutContext } from '../..'; +import { useFlyoutContext, useStartServices } from '../../../../hooks'; export const EnrollmentRecommendation: React.FunctionComponent<{ showStandaloneTab: () => void; }> = ({ showStandaloneTab }) => { - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); const { docLinks } = useStartServices(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx index 5902f73cae3bc..0f9a8a3bbdd50 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx @@ -164,7 +164,7 @@ export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClos ), link: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index 57da2fcf36d76..78ceb6293d3ce 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,14 +5,21 @@ * 2.0. */ -import React, { createContext, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { Router, Route, Switch, useHistory } from 'react-router-dom'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPortal } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FLEET_ROUTING_PATHS } from '../../constants'; -import { Loading, Error, AgentEnrollmentFlyout, FleetServerFlyout } from '../../components'; -import { useConfig, useFleetStatus, useBreadcrumbs, useAuthz, useGetSettings } from '../../hooks'; +import { Loading, Error } from '../../components'; +import { + useConfig, + useFleetStatus, + useBreadcrumbs, + useAuthz, + useGetSettings, + useFlyoutContext, +} from '../../hooks'; import { DefaultLayout, WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -21,30 +28,16 @@ import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; import { FleetServerUpgradeModal } from './components/fleet_server_upgrade_modal'; -// TODO: Move all instances of toggling these flyouts to a global context object to avoid cases in which -// we can render duplicate "stacked" flyouts -export const agentFlyoutContext = createContext< - | { - openEnrollmentFlyout: () => void; - closeEnrollmentFlyout: () => void; - openFleetServerFlyout: () => void; - closeFleetServerFlyout: () => void; - } - | undefined ->(undefined); - export const AgentsApp: React.FunctionComponent = () => { useBreadcrumbs('agent_list'); const history = useHistory(); const { agents } = useConfig(); const hasFleetAllPrivileges = useAuthz().fleet.all; const fleetStatus = useFleetStatus(); + const flyoutContext = useFlyoutContext(); const settings = useGetSettings(); - const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); - const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); - const [fleetServerModalVisible, setFleetServerModalVisible] = useState(false); const onCloseFleetServerModal = useCallback(() => { setFleetServerModalVisible(false); @@ -100,7 +93,7 @@ export const AgentsApp: React.FunctionComponent = () => { setIsEnrollmentFlyoutOpen(true)} + onClick={() => flyoutContext.openEnrollmentFlyout()} data-test-subj="addAgentBtnTop" > @@ -111,49 +104,24 @@ export const AgentsApp: React.FunctionComponent = () => { ) : undefined; return ( - setIsEnrollmentFlyoutOpen(true), - closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), - openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), - closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), - }} - > - - - - - - - - {fleetServerModalVisible && ( - - )} - {hasOnlyFleetServerMissingRequirement ? ( - - ) : ( - - )} - - - - - {isEnrollmentFlyoutOpen && ( - - setIsEnrollmentFlyoutOpen(false)} - /> - - )} - - {isFleetServerFlyoutOpen && ( - - setIsFleetServerFlyoutOpen(false)} /> - - )} - - + + + + + + + + {fleetServerModalVisible && ( + + )} + {hasOnlyFleetServerMissingRequirement ? ( + + ) : ( + + )} + + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index 717e528443a3f..fb0d7f625488a 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch } from 'react-router-dom'; import useObservable from 'react-use/lib/useObservable'; @@ -22,14 +22,17 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { ConfigContext, FleetStatusProvider, KibanaVersionContext } from '../../hooks'; -import { AgentPolicyContextProvider } from './hooks'; +import { FleetServerFlyout } from '../fleet/components'; + +import { AgentPolicyContextProvider, useFlyoutContext } from './hooks'; import { INTEGRATIONS_ROUTING_PATHS, pagePathGetters } from './constants'; import type { UIExtensionsStorage } from './types'; import { EPMApp } from './sections/epm'; -import { PackageInstallProvider, UIExtensionsContext } from './hooks'; +import { PackageInstallProvider, UIExtensionsContext, FlyoutContextProvider } from './hooks'; import { IntegrationsHeader } from './components/header'; +import { AgentEnrollmentFlyout } from './components'; const EmptyContext = () => <>; @@ -81,9 +84,11 @@ export const IntegrationsAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - - {children} - + + + {children} + + @@ -104,6 +109,8 @@ export const IntegrationsAppContext: React.FC<{ ); export const AppRoutes = memo(() => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -131,6 +138,22 @@ export const AppRoutes = memo(() => { }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); }); diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx index 5b777803552fb..ca7293a8c99c9 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx @@ -26,8 +26,9 @@ tar xzvf elastic-agent-${kibanaVersion}-darwin-x86_64.tar.gz cd elastic-agent-${kibanaVersion}-darwin-x86_64 sudo ./elastic-agent install ${enrollArgs}`; - const windowsCommand = `wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip -Expand-Archive .\\elastic-agent-${kibanaVersion}-windows-x86_64.zip + const windowsCommand = `$ProgressPreference = 'SilentlyContinue' +wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip +Expand-Archive .\\elastic-agent-${kibanaVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${kibanaVersion}-windows-x86_64 .\\elastic-agent.exe install ${enrollArgs}`; diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx index 75378cdc86378..2d9326cf6cbb1 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx @@ -23,8 +23,9 @@ tar xzvf elastic-agent-${kibanaVersion}-darwin-x86_64.tar.gz cd elastic-agent-${kibanaVersion}-darwin-x86_64 sudo ./elastic-agent install`; - const STANDALONE_RUN_INSTRUCTIONS_WINDOWS = `wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip -Expand-Archive .\elastic-agent-${kibanaVersion}-windows-x86_64.zip + const STANDALONE_RUN_INSTRUCTIONS_WINDOWS = `$ProgressPreference = 'SilentlyContinue' +wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip +Expand-Archive .\elastic-agent-${kibanaVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${kibanaVersion}-windows-x86_64 .\\elastic-agent.exe install`; diff --git a/x-pack/plugins/fleet/public/components/platform_selector.tsx b/x-pack/plugins/fleet/public/components/platform_selector.tsx index ae18f56b4b3ac..0209bf8e31fd6 100644 --- a/x-pack/plugins/fleet/public/components/platform_selector.tsx +++ b/x-pack/plugins/fleet/public/components/platform_selector.tsx @@ -50,6 +50,14 @@ export const PlatformSelector: React.FunctionComponent = ({ /> ); + const commandsByPlatform: Record = { + linux: linuxCommand, + mac: macCommand, + windows: windowsCommand, + deb: linuxDebCommand, + rpm: linuxRpmCommand, + }; + return ( <> {isK8s ? ( @@ -67,39 +75,22 @@ export const PlatformSelector: React.FunctionComponent = ({ })} /> - {platform === 'linux' && ( - - {linuxCommand} - - )} - {platform === 'mac' && ( - - {macCommand} - - )} - {platform === 'windows' && ( - - {windowsCommand} - - )} - {platform === 'deb' && ( - <> - {systemPackageCallout} - - - {linuxDebCommand} - - - )} - {platform === 'rpm' && ( + {(platform === 'deb' || platform === 'rpm') && ( <> {systemPackageCallout} - - {linuxRpmCommand} - )} + + {commandsByPlatform[platform]} + )} diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 5c995131396b4..579d1ab5bc3de 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -27,3 +27,4 @@ export * from './use_platform'; export * from './use_agent_policy_refresh'; export * from './use_package_installations'; export * from './use_agent_enrollment_flyout_data'; +export * from './use_flyout_context'; diff --git a/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx b/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx new file mode 100644 index 0000000000000..0ddc358ab2fbf --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_flyout_context.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, { createContext, useContext, useState } from 'react'; + +const agentFlyoutContext = createContext< + | { + isEnrollmentFlyoutOpen: boolean; + openEnrollmentFlyout: () => void; + closeEnrollmentFlyout: () => void; + isFleetServerFlyoutOpen: boolean; + openFleetServerFlyout: () => void; + closeFleetServerFlyout: () => void; + } + | undefined +>(undefined); + +export const FlyoutContextProvider: React.FunctionComponent = ({ children }) => { + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); + + return ( + setIsEnrollmentFlyoutOpen(true), + closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), + isFleetServerFlyoutOpen, + openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), + closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), + }} + > + {children} + + ); +}; + +export const useFlyoutContext = () => { + const context = useContext(agentFlyoutContext); + + if (!context) { + throw new Error('useFlyoutContext must be used within a FlyoutContextProvider'); + } + + return context; +}; diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 4f3cad9edab26..80a6eac2d81b0 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -33,7 +33,7 @@ export const postNewAgentActionHandlerBuilder = function ( const savedAgentAction = await actionsService.createAgentAction(esClient, { created_at: new Date().toISOString(), ...newAgentAction, - agent_id: agent.id, + agents: [agent.id], }); const body: PostNewAgentActionResponse = { diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 3ea8060e8e492..7a13e1612cb0c 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -8,20 +8,26 @@ import uuid from 'uuid'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Agent, AgentAction, FleetServerAgentAction } from '../../../common/types/models'; +import type { + Agent, + AgentAction, + NewAgentAction, + FleetServerAgentAction, +} from '../../../common/types/models'; import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( esClient: ElasticsearchClient, - newAgentAction: Omit + newAgentAction: NewAgentAction ): Promise { - const id = uuid.v4(); + const id = newAgentAction.id ?? uuid.v4(); + const timestamp = new Date().toISOString(); const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [newAgentAction.agent_id], + agents: newAgentAction.agents, action_id: id, data: newAgentAction.data, type: newAgentAction.type, @@ -37,6 +43,7 @@ export async function createAgentAction( return { id, ...newAgentAction, + created_at: timestamp, }; } @@ -62,7 +69,7 @@ export async function bulkCreateAgentActions( const body: FleetServerAgentAction = { '@timestamp': new Date().toISOString(), expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [action.agent_id], + agents: action.agents, action_id: action.id, data: action.data, type: action.type, diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index d342e8d54bb84..c842bfb8f72c7 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -20,7 +20,7 @@ import { bulkUpdateAgents, } from './crud'; import type { GetAgentsOptions } from '.'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import { searchHitToAgent } from './helpers'; export async function reassignAgent( @@ -42,7 +42,7 @@ export async function reassignAgent( }); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: new Date().toISOString(), type: 'POLICY_REASSIGN', }); @@ -161,14 +161,11 @@ export async function reassignAgents( }); const now = new Date().toISOString(); - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'POLICY_REASSIGN', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'POLICY_REASSIGN', + }); return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts index a26194ef6ddeb..596c7db5d8472 100644 --- a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts +++ b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts @@ -5,18 +5,9 @@ * 2.0. */ -import Boom from '@hapi/boom'; import type { SavedObject } from '@kbn/core/server'; -import type { - Agent, - AgentSOAttributes, - AgentAction, - AgentPolicyAction, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, - BaseAgentActionSOAttributes, -} from '../../types'; +import type { Agent, AgentSOAttributes } from '../../types'; export function savedObjectToAgent(so: SavedObject): Agent { if (so.error) { @@ -33,58 +24,3 @@ export function savedObjectToAgent(so: SavedObject): Agent { packages: so.attributes.packages ?? [], }; } - -export function savedObjectToAgentAction(so: SavedObject): AgentAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentPolicyAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentAction | AgentPolicyAction { - if (so.error) { - if (so.error.statusCode === 404) { - throw Boom.notFound(so.error.message); - } - - throw new Error(so.error.message); - } - - // If it's an AgentPolicyAction - if (isPolicyActionSavedObject(so)) { - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - policy_id: so.attributes.policy_id, - policy_revision: so.attributes.policy_revision, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; - } - - if (!isAgentActionSavedObject(so)) { - throw new Error(`Malformed saved object AgentAction ${so.id}`); - } - - // If it's an AgentAction - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - agent_id: so.attributes.agent_id, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; -} - -export function isAgentActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentActionSOAttributes).agent_id !== undefined; -} - -export function isPolicyActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentPolicyActionSOAttributes).policy_id !== undefined; -} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index e6327c16c3ccc..45f40916598a1 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -96,7 +96,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -116,7 +116,7 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -175,7 +175,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -232,18 +232,6 @@ describe('unenrollAgents (plural)', () => { function createClientMock() { const soClientMock = savedObjectsClientMock.create(); - // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) - // @ts-expect-error - soClientMock.create.mockResolvedValue({ attributes: { agent_id: 'tata' } }); - soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { - return { - saved_objects: [await soClientMock.create(type, attributes)], - }; - }); - soClientMock.bulkUpdate.mockResolvedValue({ - saved_objects: [], - }); - soClientMock.get.mockImplementation(async (_, id) => { switch (id) { case regularAgentPolicySO.id: diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 461caff1ada6c..92dd0f1ba22f8 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -11,7 +11,7 @@ import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentById, @@ -53,7 +53,7 @@ export async function unenrollAgent( } const now = new Date().toISOString(); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, type: 'UNENROLL', }); @@ -105,14 +105,11 @@ export async function unenrollAgents( await invalidateAPIKeysForAgents(agentsToUpdate); } else { // Create unenroll action for each agent - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'UNENROLL', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'UNENROLL', + }); } // Update the necessary agents diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 36568ca6e0004..00470d5e25f8d 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -17,7 +17,7 @@ import { import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; -import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentDocuments, @@ -59,7 +59,7 @@ export async function sendUpgradeAgentAction({ } await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, data, ack_data: data, @@ -75,8 +75,8 @@ export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: ({ agents: Agent[] } | GetAgentsOptions) & { - sourceUri: string | undefined; version: string; + sourceUri?: string | undefined; force?: boolean; } ) { @@ -158,16 +158,13 @@ export async function sendUpgradeAgentsActions( source_uri: options.sourceUri, }; - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - data, - ack_data: data, - type: 'UPGRADE', - })) - ); + await createAgentAction(esClient, { + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + agents: agentsToUpdate.map((agent) => agent.id), + }); await bulkUpdateAgents( esClient, diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 6356ff8aa6cac..37dde581d4b8f 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,10 +12,6 @@ export type { AgentStatus, AgentType, AgentAction, - AgentPolicyAction, - BaseAgentActionSOAttributes, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index 62f322e4f48f3..e89189df7667b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -159,7 +159,7 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { id="xpack.indexLifecycleMgmt.editPolicy.deletePhase.waitForSnapshotDescription" defaultMessage="Specify a snapshot policy to be executed before the deletion of the index. This ensures that a snapshot of the deleted index is available." />{' '} - + } titleSize="xs" diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts index 84aba75239ed0..4f04fc1753c82 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts @@ -39,7 +39,7 @@ export const getDatatable = ( ): DatatableExpressionFunction => ({ name: 'lens_datatable', type: 'render', - inputTypes: ['lens_multitable'], + inputTypes: ['datatable'], help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', { defaultMessage: 'Datatable renderer', }), diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts index 713f929d74420..464c70a412397 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts @@ -8,8 +8,8 @@ import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import type { DatatableColumnMeta, ExecutionContext } from '@kbn/expressions-plugin'; -import { FormatFactory, LensMultiTable } from '../../types'; +import type { Datatable, DatatableColumnMeta, ExecutionContext } from '@kbn/expressions-plugin'; +import { FormatFactory } from '../../types'; import { transposeTable } from './transpose_helpers'; import { computeSummaryRowForColumn } from './summary'; import { getSortingCriteria } from './sorting'; @@ -23,11 +23,13 @@ export const datatableFn = ( getFormatFactory: (context: ExecutionContext) => FormatFactory | Promise ): DatatableExpressionFunction['fn'] => - async (data, args, context) => { - const [firstTable] = Object.values(data.tables); + async (table, args, context) => { if (context?.inspectorAdapters?.tables) { + context.inspectorAdapters.tables.reset(); + context.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( - Object.values(data.tables)[0], + table, [ [ args.columns.map((column) => column.columnId), @@ -42,27 +44,27 @@ export const datatableFn = context.inspectorAdapters.tables.logDatatable('default', logTable); } - let untransposedData: LensMultiTable | undefined; + let untransposedData: Datatable | undefined; // do the sorting at this level to propagate it also at CSV download const [layerId] = Object.keys(context.inspectorAdapters.tables || {}); const formatters: Record> = {}; const formatFactory = await getFormatFactory(context); - firstTable.columns.forEach((column) => { + table.columns.forEach((column) => { formatters[column.id] = formatFactory(column.meta?.params); }); const hasTransposedColumns = args.columns.some((c) => c.isTransposed); if (hasTransposedColumns) { // store original shape of data separately - untransposedData = cloneDeep(data); + untransposedData = cloneDeep(table); // transposes table and args inplace - transposeTable(args, firstTable, formatters); + transposeTable(args, table, formatters); } const { sortingColumnId: sortBy, sortingDirection: sortDirection } = args; - const columnsReverseLookup = firstTable.columns.reduce< + const columnsReverseLookup = table.columns.reduce< Record >((memo, { id, name, meta }, i) => { memo[id] = { name, index: i, meta }; @@ -73,7 +75,7 @@ export const datatableFn = for (const column of columnsWithSummary) { column.summaryRowValue = computeSummaryRowForColumn( column, - firstTable, + table, formatters, formatFactory({ id: 'number' }) ); @@ -92,20 +94,21 @@ export const datatableFn = sortDirection ); // replace the table here - context.inspectorAdapters.tables[layerId].rows = (firstTable.rows || []) + context.inspectorAdapters.tables[layerId].rows = (table.rows || []) .slice() .sort(sortingCriteria); // replace also the local copy - firstTable.rows = context.inspectorAdapters.tables[layerId].rows; + table.rows = context.inspectorAdapters.tables[layerId].rows; } else { args.sortingColumnId = undefined; args.sortingDirection = 'none'; } + return { type: 'render', as: 'lens_datatable_renderer', value: { - data, + data: table, untransposedData, args, }, diff --git a/x-pack/plugins/lens/common/expressions/datatable/types.ts b/x-pack/plugins/lens/common/expressions/datatable/types.ts index 9e6315bb856d0..a78018fca90f6 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/types.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/types.ts @@ -5,13 +5,12 @@ * 2.0. */ -import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; -import type { LensMultiTable } from '../../types'; +import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; import type { DatatableArgs } from './datatable'; export interface DatatableProps { - data: LensMultiTable; - untransposedData?: LensMultiTable; + data: Datatable; + untransposedData?: Datatable; args: DatatableArgs; } @@ -23,7 +22,7 @@ export interface DatatableRender { export type DatatableExpressionFunction = ExpressionFunctionDefinition< 'lens_datatable', - LensMultiTable, + Datatable, DatatableArgs, Promise >; diff --git a/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts b/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts deleted file mode 100644 index 2e1ce28534a27..0000000000000 --- a/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ExpressionTypeDefinition } from '@kbn/expressions-plugin/common'; -import { LensMultiTable } from '../../types'; - -const name = 'lens_multitable'; - -type Input = LensMultiTable; - -export type LensMultitableExpressionTypeDefinition = ExpressionTypeDefinition< - typeof name, - Input, - Input ->; - -export const lensMultitable: LensMultitableExpressionTypeDefinition = { - name, - to: { - datatable: (input: Input) => { - return Object.values(input.tables)[0]; - }, - }, -}; diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index 47ff8318447b2..2007a61b11bf9 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -8,8 +8,5 @@ export * from './counter_rate'; export * from './format_column'; export * from './rename_columns'; -export * from './merge_tables'; export * from './time_scale'; export * from './datatable'; - -export * from './expression_types'; diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts b/x-pack/plugins/lens/common/expressions/merge_tables/index.ts deleted file mode 100644 index 2e0dbc4b44264..0000000000000 --- a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts +++ /dev/null @@ -1,86 +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 { i18n } from '@kbn/i18n'; -import type { - ExpressionFunctionDefinition, - Datatable, - ExecutionContext, -} from '@kbn/expressions-plugin/common'; -import { toAbsoluteDates } from '@kbn/data-plugin/common'; -import type { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; - -import type { Adapters } from '@kbn/inspector-plugin/common'; -import type { LensMultiTable } from '../../types'; - -interface MergeTables { - layerIds: string[]; - tables: Datatable[]; -} - -export const mergeTables: ExpressionFunctionDefinition< - 'lens_merge_tables', - ExpressionValueSearchContext | null, - MergeTables, - LensMultiTable, - ExecutionContext -> = { - name: 'lens_merge_tables', - type: 'lens_multitable', - help: i18n.translate('xpack.lens.functions.mergeTables.help', { - defaultMessage: - 'A helper to merge any number of kibana tables into a single table and expose it via inspector adapter', - }), - args: { - layerIds: { - types: ['string'], - help: '', - multi: true, - }, - tables: { - types: ['datatable'], - help: '', - multi: true, - }, - }, - inputTypes: ['kibana_context', 'null'], - fn(input, { layerIds, tables }, context) { - const resultTables: Record = {}; - - if (context.inspectorAdapters?.tables) { - context.inspectorAdapters.tables.reset(); - context.inspectorAdapters.tables.allowCsvExport = true; - } - - tables.forEach((table, index) => { - resultTables[layerIds[index]] = table; - }); - - return { - type: 'lens_multitable', - tables: resultTables, - dateRange: getDateRange(input), - }; - }, -}; - -function getDateRange(value?: ExpressionValueSearchContext | null) { - if (!value || !value.timeRange) { - return; - } - - const dateRange = toAbsoluteDates(value.timeRange); - - if (!dateRange) { - return; - } - - return { - fromDate: dateRange.from, - toDate: dateRange.to, - }; -} diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts deleted file mode 100644 index 4558bdfe68661..0000000000000 --- a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts +++ /dev/null @@ -1,114 +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 moment from 'moment'; -import { mergeTables } from '.'; -import type { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; -import { - Datatable, - ExecutionContext, - DefaultInspectorAdapters, - TablesAdapter, -} from '@kbn/expressions-plugin'; - -describe('lens_merge_tables', () => { - const sampleTable1: Datatable = { - type: 'datatable', - columns: [ - { id: 'bucket', name: 'A', meta: { type: 'string' } }, - { id: 'count', name: 'Count', meta: { type: 'number' } }, - ], - rows: [ - { bucket: 'a', count: 5 }, - { bucket: 'b', count: 10 }, - ], - }; - - const sampleTable2: Datatable = { - type: 'datatable', - columns: [ - { id: 'bucket', name: 'C', meta: { type: 'string' } }, - { id: 'avg', name: 'Average', meta: { type: 'number' } }, - ], - rows: [ - { bucket: 'a', avg: 2.5 }, - { bucket: 'b', avg: 9 }, - ], - }; - - it('should produce a row with the nested table as defined', () => { - expect( - mergeTables.fn( - null, - { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, - // eslint-disable-next-line - {} as any - ) - ).toEqual({ - tables: { first: sampleTable1, second: sampleTable2 }, - type: 'lens_multitable', - }); - }); - - it('should reset the current tables in the tables inspector', () => { - const adapters = { - tables: new TablesAdapter(), - } as DefaultInspectorAdapters; - - const resetSpy = jest.spyOn(adapters.tables, 'reset'); - - mergeTables.fn(null, { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, { - inspectorAdapters: adapters, - } as ExecutionContext); - expect(resetSpy).toHaveBeenCalled(); - }); - - it('should pass the date range along', () => { - expect( - mergeTables.fn( - { - type: 'kibana_context', - timeRange: { - from: '2019-01-01T05:00:00.000Z', - to: '2020-01-01T05:00:00.000Z', - }, - }, - { layerIds: ['first', 'second'], tables: [] }, - // eslint-disable-next-line - {} as any - ) - ).toMatchInlineSnapshot(` - Object { - "dateRange": Object { - "fromDate": 2019-01-01T05:00:00.000Z, - "toDate": 2020-01-01T05:00:00.000Z, - }, - "tables": Object {}, - "type": "lens_multitable", - } - `); - }); - - it('should handle this week now/w', () => { - const { dateRange } = mergeTables.fn( - { - type: 'kibana_context', - timeRange: { - from: 'now/w', - to: 'now/w', - }, - }, - { layerIds: ['first', 'second'], tables: [] }, - // eslint-disable-next-line - {} as any - ); - - expect(moment.duration(moment().startOf('week').diff(dateRange!.fromDate)).asDays()).toEqual(0); - - expect(moment.duration(moment().endOf('week').diff(dateRange!.toDate)).asDays()).toEqual(0); - }); -}); diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index f85c8ba88a076..d7432e0f10b6f 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -10,8 +10,8 @@ import { Position } from '@elastic/charts'; import { $Values } from '@kbn/utility-types'; import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; -import type { Datatable } from '@kbn/expressions-plugin/common'; import type { ColorMode } from '@kbn/charts-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; import { CategoryDisplay, layerTypes, @@ -40,15 +40,6 @@ export interface PersistableFilter extends Filter { meta: PersistableFilterMeta; } -export interface LensMultiTable { - type: 'lens_multitable'; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - export type SortingHint = 'version'; export type CustomPaletteParamsConfig = CustomPaletteParams & { @@ -83,7 +74,7 @@ export interface SharedPieLayerState { percentDecimals?: number; emptySizeRatio?: number; legendMaxLines?: number; - legendSize?: number; + legendSize?: LegendSize; truncateLegend?: boolean; } diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index e5a55322a2f10..adf791e8d2f48 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -16,6 +16,7 @@ "visualizations", "dashboard", "uiActions", + "uiActionsEnhanced", "embeddable", "share", "presentationUtil", diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts index 118abdcb77c8a..f11fc098b50d9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -17,7 +17,6 @@ import { createGridHideHandler, createTransposeColumnFilterHandler, } from './table_actions'; -import { LensMultiTable } from '../../../common'; import { LensGridDirection, ColumnConfig } from '../../../common/expressions'; function getDefaultConfig(): ColumnConfig { @@ -49,17 +48,8 @@ function createTableRef( }; } -function createUntransposedRef(options?: { - withDate: boolean; -}): React.MutableRefObject { - return { - current: { - type: 'lens_multitable', - tables: { - first: createTableRef(options).current, - }, - }, - }; +function createUntransposedRef(options?: { withDate: boolean }): React.MutableRefObject { + return { current: createTableRef(options).current }; } describe('Table actions', () => { @@ -147,13 +137,13 @@ describe('Table actions', () => { it('should set a filter on click with the correct configuration', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: true }); - tableRef.current.tables.first.rows = [{ a: 123456 }]; + tableRef.current.rows = [{ a: 123456 }]; const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 123456, }, ], @@ -164,7 +154,7 @@ describe('Table actions', () => { { column: 0, row: 0, - table: tableRef.current.tables.first, + table: tableRef.current, value: 123456, }, ], @@ -175,13 +165,13 @@ describe('Table actions', () => { it('should set a negate filter on click with the correct configuration', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: true }); - tableRef.current.tables.first.rows = [{ a: 123456 }]; + tableRef.current.rows = [{ a: 123456 }]; const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 123456, }, ], @@ -192,7 +182,7 @@ describe('Table actions', () => { { column: 0, row: 0, - table: tableRef.current.tables.first, + table: tableRef.current, value: 123456, }, ], @@ -204,7 +194,7 @@ describe('Table actions', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: false }); const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); - tableRef.current.tables.first.columns = [ + tableRef.current.columns = [ { id: 'a', name: 'a', @@ -220,7 +210,7 @@ describe('Table actions', () => { }, }, ]; - tableRef.current.tables.first.rows = [ + tableRef.current.rows = [ { a: 'a1', b: 'b1', @@ -242,11 +232,11 @@ describe('Table actions', () => { filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 'a2', }, { - originalBucketColumn: tableRef.current.tables.first.columns[1], + originalBucketColumn: tableRef.current.columns[1], value: 'b3', }, ], @@ -257,13 +247,13 @@ describe('Table actions', () => { { column: 0, row: 1, - table: tableRef.current.tables.first, + table: tableRef.current, value: 'a2', }, { column: 1, row: 2, - table: tableRef.current.tables.first, + table: tableRef.current, value: 'b3', }, ], diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index 817b1d10fd82e..ba7e957de083a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -7,8 +7,7 @@ import type { EuiDataGridSorting } from '@elastic/eui'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin'; -import type { LensFilterEvent } from '../../types'; -import type { LensMultiTable } from '../../../common'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; import type { LensResizeAction, LensSortAction, LensToggleAction } from './types'; import type { ColumnConfig, LensGridDirection } from '../../../common/expressions'; import { getOriginalId } from '../../../common/expressions'; @@ -72,10 +71,10 @@ export const createGridHideHandler = export const createGridFilterHandler = ( tableRef: React.MutableRefObject, - onClickValue: (data: LensFilterEvent['data']) => void + onClickValue: (data: ClickTriggerEvent['data']) => void ) => (field: string, value: unknown, colIndex: number, rowIndex: number, negate: boolean = false) => { - const data: LensFilterEvent['data'] = { + const data: ClickTriggerEvent['data'] = { negate, data: [ { @@ -92,17 +91,17 @@ export const createGridFilterHandler = export const createTransposeColumnFilterHandler = ( - onClickValue: (data: LensFilterEvent['data']) => void, - untransposedDataRef: React.MutableRefObject + onClickValue: (data: ClickTriggerEvent['data']) => void, + untransposedDataRef: React.MutableRefObject ) => ( bucketValues: Array<{ originalBucketColumn: DatatableColumn; value: unknown }>, negate: boolean = false ) => { if (!untransposedDataRef.current) return; - const originalTable = Object.values(untransposedDataRef.current.tables)[0]; + const originalTable = untransposedDataRef.current; - const data: LensFilterEvent['data'] = { + const data: ClickTriggerEvent['data'] = { negate, data: bucketValues.map(({ originalBucketColumn, value }) => { const columnIndex = originalTable.columns.findIndex( diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 96ffbf371525e..bcf6f50d2bd46 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -20,59 +20,53 @@ import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; -import { LensMultiTable } from '../../../common'; import { DatatableProps } from '../../../common/expressions'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { IUiSettingsClient } from '@kbn/core/public'; -import { RenderMode } from '@kbn/expressions-plugin'; +import { Datatable, RenderMode } from '@kbn/expressions-plugin'; import { LENS_EDIT_PAGESIZE_ACTION } from './constants'; function sampleArgs() { const indexPatternId = 'indexPatternId'; - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'string', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'terms', indexPatternId }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'date', - field: 'b', - source: 'esaggs', - sourceParams: { - type: 'date_histogram', - indexPatternId, - }, - }, - }, - { - id: 'c', - name: 'c', - meta: { - type: 'number', - source: 'esaggs', - field: 'c', - sourceParams: { indexPatternId, type: 'count' }, - }, + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, }, - ], - rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, }, - }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, + }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; const args: DatatableProps['args'] = { @@ -175,13 +169,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -206,7 +194,7 @@ describe('DatatableComponent', () => { { column: 0, row: 0, - table: data.tables.l1, + table: data, value: 'shoes', }, ], @@ -220,13 +208,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -251,7 +233,7 @@ describe('DatatableComponent', () => { { column: 1, row: 0, - table: data.tables.l1, + table: data, value: 1588024800000, }, ], @@ -261,35 +243,30 @@ describe('DatatableComponent', () => { }); test('it invokes executeTriggerActions with correct context on click on timefield from range', async () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'date', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'date_range', indexPatternId: 'a' }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'number', - source: 'esaggs', - sourceParams: { type: 'count', indexPatternId: 'a' }, - }, - }, - ], - rows: [{ a: 1588024800000, b: 3 }], + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'date', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'date_range', indexPatternId: 'a' }, + }, }, - }, + { + id: 'b', + name: 'b', + meta: { + type: 'number', + source: 'esaggs', + sourceParams: { type: 'count', indexPatternId: 'a' }, + }, + }, + ], + rows: [{ a: 1588024800000, b: 3 }], }; const args: DatatableProps['args'] = { @@ -305,13 +282,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -336,7 +307,7 @@ describe('DatatableComponent', () => { { column: 0, row: 0, - table: data.tables.l1, + table: data, value: 1588024800000, }, ], @@ -350,13 +321,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -377,14 +342,9 @@ describe('DatatableComponent', () => { test('it shows emptyPlaceholder for undefined bucketed data', () => { const { args, data } = sampleArgs(); - const emptyData: LensMultiTable = { + const emptyData: Datatable = { ...data, - tables: { - l1: { - ...data.tables.l1, - rows: [{ a: undefined, b: undefined, c: 0 }], - }, - }, + rows: [{ a: undefined, b: undefined, c: 0 }], }; const component = shallow( @@ -551,8 +511,7 @@ describe('DatatableComponent', () => { test('it detect last_value filtered metric type', () => { const { data, args } = sampleArgs(); - const table = data.tables.l1; - const column = table.columns[1]; + const column = data.columns[1]; column.meta = { ...column.meta, @@ -560,7 +519,7 @@ describe('DatatableComponent', () => { type: 'number', sourceParams: { ...column.meta.sourceParams, type: 'filtered_metric' }, }; - table.rows[0].b = 'Hello'; + data.rows[0].b = 'Hello'; const wrapper = shallow( { ); // mnake a copy of the data, changing only the name of the first column const newData = copyData(data); - newData.tables.l1.columns[0].name = 'new a'; + newData.columns[0].name = 'new a'; wrapper.setProps({ data: newData }); wrapper.update(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 76d677f74fe91..cf6cd1c635cee 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -20,7 +20,8 @@ import { EuiDataGridStyle, } from '@elastic/eui'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; -import type { LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; +import type { LensTableRowContextMenuEvent } from '../../types'; import type { FormatFactory } from '../../../common'; import type { LensGridDirection } from '../../../common/expressions'; import { VisualizationContainer } from '../../visualization_container'; @@ -58,8 +59,6 @@ const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 20, 30, 50, 100]; export const DatatableComponent = (props: DatatableRenderProps) => { const dataGridRef = useRef(null); - const [firstTable] = Object.values(props.data.tables); - const isInteractive = props.interactive; const [columnConfig, setColumnConfig] = useState({ @@ -67,7 +66,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { sortingColumnId: props.args.sortingColumnId, sortingDirection: props.args.sortingDirection, }); - const [firstLocalTable, updateTable] = useState(firstTable); + const [firstLocalTable, updateTable] = useState(props.data); // ** Pagination config const [pagination, setPagination] = useState<{ pageIndex: number; pageSize: number } | undefined>( @@ -94,8 +93,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { }, [props.args.columns, props.args.sortingColumnId, props.args.sortingDirection]); useDeepCompareEffect(() => { - updateTable(firstTable); - }, [firstTable]); + updateTable(props.data); + }, [props.data]); const firstTableRef = useRef(firstLocalTable); firstTableRef.current = firstLocalTable; @@ -120,7 +119,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); const onClickValue = useCallback( - (data: LensFilterEvent['data']) => { + (data: ClickTriggerEvent['data']) => { dispatchEvent({ name: 'filter', data }); }, [dispatchEvent] @@ -193,7 +192,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const isEmpty = firstLocalTable.rows.length === 0 || (bucketColumns.length && - firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); + props.data.rows.every((row) => bucketColumns.every((col) => row[col] == null))); const visibleColumns = useMemo( () => @@ -252,10 +251,10 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig.columns .filter(({ columnId }) => isNumericMap[columnId]) .map(({ columnId }) => columnId), - firstTable, + props.data, getOriginalId ); - }, [firstTable, isNumericMap, columnConfig]); + }, [props.data, isNumericMap, columnConfig]); const headerRowHeight = props.args.headerRowHeight ?? 'single'; const headerRowLines = props.args.headerRowHeightLines ?? 1; @@ -375,7 +374,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { .map((config) => ({ columnId: config.columnId, summaryRowValue: config.summaryRowValue, - ...getFinalSummaryConfiguration(config.columnId, config, firstTable), + ...getFinalSummaryConfiguration(config.columnId, config, props.data), })) .filter(({ summaryRow }) => summaryRow !== 'none'); @@ -401,7 +400,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ) : null; }; } - }, [columnConfig.columns, alignments, firstTable, columns]); + }, [columnConfig.columns, alignments, props.data, columns]); if (isEmpty) { return ( diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index 8a899780515b2..3e798c5813041 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -6,56 +6,51 @@ */ import type { DatatableProps } from '../../common/expressions'; -import type { LensMultiTable } from '../../common'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import type { FormatFactory } from '../../common'; import { getDatatable } from '../../common/expressions'; +import { Datatable } from '@kbn/expressions-plugin'; function sampleArgs() { const indexPatternId = 'indexPatternId'; - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'string', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'terms', indexPatternId }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'date', - field: 'b', - source: 'esaggs', - sourceParams: { - type: 'date_histogram', - indexPatternId, - }, - }, - }, - { - id: 'c', - name: 'c', - meta: { - type: 'number', - source: 'esaggs', - field: 'c', - sourceParams: { indexPatternId, type: 'count' }, - }, + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, }, - ], - rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, + }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, }, - }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; const args: DatatableProps['args'] = { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 1053eadce1363..3ae8057114198 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -46,16 +46,15 @@ export const getDatatableRenderer = (dependencies: { // ROW_CLICK_TRIGGER trigger. let rowHasRowClickTriggerActions: boolean[] = []; if (hasCompatibleActions) { - const table = Object.values(config.data.tables)[0]; - if (!!table) { + if (!!config.data) { rowHasRowClickTriggerActions = await Promise.all( - table.rows.map(async (row, rowIndex) => { + config.data.rows.map(async (row, rowIndex) => { try { const hasActions = await hasCompatibleActions({ name: 'tableRowContextMenuClick', data: { rowIndex, - table, + table: config.data, columns: config.args.columns.map((column) => column.columnId), }, }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 8087f43b90e72..4cc44a1b70293 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -326,7 +326,12 @@ export const getDatatableVisualization = ({ } }, - toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { + toExpression( + state, + datasourceLayers, + { title, description } = {}, + datasourceExpressionsByLayers = {} + ): Ast | null { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; @@ -346,9 +351,12 @@ export const getDatatableVisualization = ({ .filter((columnId) => datasource!.getOperationForColumnId(columnId)) .map((columnId) => columnMap[columnId]); + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; + return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: 'lens_datatable', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 91ca494866f30..796128df989b4 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -32,7 +32,6 @@ import { EditorFrame, EditorFrameProps } from './editor_frame'; import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types'; import { act } from 'react-dom/test-utils'; import { coreMock } from '@kbn/core/public/mocks'; -import { fromExpression } from '@kbn/interpreter'; import { createMockVisualization, createMockDatasource, @@ -49,6 +48,7 @@ import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; import { mockDataPlugin, mountWithProvider } from '../../mocks'; import { setState } from '../../state_management'; import { getLensInspectorService } from '../../lens_inspector_service'; +import { toExpression } from '@kbn/interpreter'; function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -209,10 +209,20 @@ describe('editor_frame', () => { it('should render the resulting expression using the expression renderer', async () => { mockDatasource.getLayers.mockReturnValue(['first']); - const props = { + const props: EditorFrameProps = { ...getDefaultProps(), visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpression({ + type: 'expression', + chain: [ + ...(datasourceExpressionsByLayers.first?.chain ?? []), + { type: 'function', function: 'testVis', arguments: {} }, + ], + }), + }, }, datasourceMap: { testDatasource: { @@ -242,137 +252,10 @@ describe('editor_frame', () => { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} + "datasource | testVis" `); }); - - it('should render individual expression for each given layer', async () => { - mockDatasource.toExpression.mockReturnValue('datasource'); - mockDatasource2.toExpression.mockImplementation((_state, layerId) => `datasource_${layerId}`); - mockDatasource.initialize.mockImplementation((initialState) => Promise.resolve(initialState)); - mockDatasource.getLayers.mockReturnValue(['first', 'second']); - mockDatasource2.initialize.mockImplementation((initialState) => - Promise.resolve(initialState) - ); - mockDatasource2.getLayers.mockReturnValue(['third']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - toExpression: () => 'datasource', - }, - testDatasource2: { - ...mockDatasource2, - toExpression: () => 'datasource_second', - }, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - instance = ( - await mountWithProvider(, { - preloadedState: { - visualization: { activeId: 'testVis', state: {} }, - datasourceStates: { - testDatasource: { - isLoading: false, - state: { - internalState1: '', - }, - }, - testDatasource2: { - isLoading: false, - state: { - internalState1: '', - }, - }, - }, - }, - }) - ).instance; - - instance.update(); - - expect( - fromExpression(instance.find(expressionRendererMock).prop('expression') as string) - ).toEqual({ - type: 'expression', - chain: expect.arrayContaining([ - expect.objectContaining({ - arguments: expect.objectContaining({ layerIds: ['first', 'second', 'third'] }), - }), - ]), - }); - expect(fromExpression(instance.find(expressionRendererMock).prop('expression') as string)) - .toMatchInlineSnapshot(` - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "kibana", - "type": "function", - }, - Object { - "arguments": Object { - "layerIds": Array [ - "first", - "second", - "third", - ], - "tables": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource_second", - "type": "function", - }, - ], - "type": "expression", - }, - ], - }, - "function": "lens_merge_tables", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "testVis", - "type": "function", - }, - ], - "type": "expression", - } - `); - }); }); describe('state update', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts index b5fa32cd8e306..367d156929714 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Ast, AstFunction, fromExpression } from '@kbn/interpreter'; +import { Ast, fromExpression } from '@kbn/interpreter'; import { DatasourceStates } from '../../state_management'; import { Visualization, DatasourceMap, DatasourceLayers } from '../../types'; @@ -16,8 +16,12 @@ export function getDatasourceExpressionsByLayers( const datasourceExpressions: Array<[string, Ast | string]> = []; Object.entries(datasourceMap).forEach(([datasourceId, datasource]) => { - const state = datasourceStates[datasourceId].state; - const layers = datasource.getLayers(datasourceStates[datasourceId].state); + const state = datasourceStates[datasourceId]?.state; + if (!state) { + return; + } + + const layers = datasource.getLayers(state); layers.forEach((layerId) => { const result = datasource.toExpression(state, layerId); @@ -40,46 +44,6 @@ export function getDatasourceExpressionsByLayers( ); } -export function prependDatasourceExpression( - visualizationExpression: Ast | string | null, - datasourceMap: DatasourceMap, - datasourceStates: DatasourceStates -): Ast | null { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasourceMap, - datasourceStates - ); - - if (datasourceExpressionsByLayers === null || visualizationExpression === null) { - return null; - } - - const parsedDatasourceExpressions = Object.entries(datasourceExpressionsByLayers); - - const datafetchExpression: AstFunction = { - type: 'function', - function: 'lens_merge_tables', - arguments: { - layerIds: parsedDatasourceExpressions.map(([id]) => id), - tables: parsedDatasourceExpressions.map(([, expr]) => expr), - }, - }; - - const parsedVisualizationExpression = - typeof visualizationExpression === 'string' - ? fromExpression(visualizationExpression) - : visualizationExpression; - - return { - type: 'expression', - chain: [ - { type: 'function', function: 'kibana', arguments: {} }, - datafetchExpression, - ...parsedVisualizationExpression.chain, - ], - }; -} - export function buildExpression({ visualization, visualizationState, @@ -101,31 +65,26 @@ export function buildExpression({ return null; } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasourceMap, - datasourceStates - ); + const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( + datasourceMap, + datasourceStates + ); - const visualizationExpression = visualization.toExpression( - visualizationState, - datasourceLayers, - { - title, - description, - }, - datasourceExpressionsByLayers ?? undefined - ); + const visualizationExpression = visualization.toExpression( + visualizationState, + datasourceLayers, + { + title, + description, + }, + datasourceExpressionsByLayers ?? undefined + ); - return typeof visualizationExpression === 'string' - ? fromExpression(visualizationExpression) - : visualizationExpression; + if (datasourceExpressionsByLayers === null || visualizationExpression === null) { + return null; } - const visualizationExpression = visualization.toExpression(visualizationState, datasourceLayers, { - title, - description, - }); - - return prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates); + return typeof visualizationExpression === 'string' + ? fromExpression(visualizationExpression) + : visualizationExpression; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index cda496aa693e8..7eff9a5961e83 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -386,9 +386,7 @@ describe('suggestion_panel', () => { const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; expect(passedExpression).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} - | test + "test | expression" `); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index dc36e0a671cf0..abd6da25c52ea 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -39,10 +39,7 @@ import { DatasourceLayers, } from '../../types'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; -import { - getDatasourceExpressionsByLayers, - prependDatasourceExpression, -} from './expression_helpers'; +import { getDatasourceExpressionsByLayers } from './expression_helpers'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; import { getMissingIndexPattern, @@ -522,22 +519,15 @@ function getPreviewExpression( }); } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasources, - datasourceStates - ); - - return visualization.toPreviewExpression( - visualizableState.visualizationState, - suggestionFrameApi.datasourceLayers, - datasourceExpressionsByLayers ?? undefined - ); - } + const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( + datasources, + datasourceStates + ); return visualization.toPreviewExpression( visualizableState.visualizationState, - suggestionFrameApi.datasourceLayers + suggestionFrameApi.datasourceLayers, + datasourceExpressionsByLayers ?? undefined ); } @@ -561,15 +551,11 @@ function preparePreviewExpression( } : datasourceStates; - const previewExprDatasourcesStates = visualization.shouldBuildDatasourceExpressionManually?.() - ? datasourceStatesWithSuggestions - : datasourceStates; - const expression = getPreviewExpression( visualizableState, visualization, datasourceMap, - previewExprDatasourcesStates, + datasourceStatesWithSuggestions, framePublicAPI ); @@ -577,9 +563,5 @@ function preparePreviewExpression( return; } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - return typeof expression === 'string' ? fromExpression(expression) : expression; - } - - return prependDatasourceExpression(expression, datasourceMap, datasourceStatesWithSuggestions); + return typeof expression === 'string' ? fromExpression(expression) : expression; } 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 6e546459a7011..d12d4beb02f2c 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 @@ -26,7 +26,6 @@ jest.mock('../../../debounced_component', () => { import { WorkspacePanel } from './workspace_panel'; import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; -import { fromExpression } from '@kbn/interpreter'; import { buildExistsFilter } from '@kbn/es-query'; import { coreMock } from '@kbn/core/public/mocks'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -44,6 +43,7 @@ import { import { getLensInspectorService } from '../../../lens_inspector_service'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; import { disableAutoApply, enableAutoApply } from '../../../state_management/lens_slice'; +import { Ast, toExpression } from '@kbn/interpreter'; const defaultPermissions: Record>> = { navLinks: { management: true }, @@ -73,6 +73,19 @@ const defaultProps = { toggleFullscreen: jest.fn(), }; +const toExpr = ( + datasourceExpressionsByLayers: Record, + fn: string = 'testVis', + layerId: string = 'first' +) => + toExpression({ + type: 'expression', + chain: [ + ...(datasourceExpressionsByLayers[layerId]?.chain ?? []), + { type: 'function', function: fn, arguments: {} }, + ], + }); + const SELECTORS = { applyChangesButton: 'button[data-test-subj="lnsApplyChanges__toolbar"]', dragDropPrompt: '[data-test-subj="workspace-drag-drop-prompt"]', @@ -148,7 +161,11 @@ describe('workspace_panel', () => { 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} />, @@ -177,7 +194,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} /> @@ -188,8 +209,7 @@ describe('workspace_panel', () => { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} + "datasource | testVis" `); }); @@ -210,7 +230,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} />, @@ -228,26 +252,28 @@ describe('workspace_panel', () => { // allows initial render expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); mockDatasource.toExpression.mockReturnValue('new-datasource'); act(() => { instance.setProps({ visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'new-vis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers, 'new-vis'), + } as Visualization, }, }); }); instance.update(); expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); act(() => { mounted.lensStore.dispatch(applyChanges()); @@ -256,16 +282,19 @@ describe('workspace_panel', () => { // should update expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} - | new-vis" - `); + "new-datasource + | new-vis" + `); mockDatasource.toExpression.mockReturnValue('other-new-datasource'); act(() => { instance.setProps({ visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'other-new-vis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers, 'other-new-vis'), + } as Visualization, }, }); mounted.lensStore.dispatch(enableAutoApply()); @@ -274,10 +303,9 @@ describe('workspace_panel', () => { // reenabling auto-apply triggers an update as well expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={other-new-datasource} - | other-new-vis" - `); + "other-new-datasource + | other-new-vis" + `); }); it('should base saveability on working changes when auto-apply disabled', async () => { @@ -305,7 +333,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} /> @@ -318,10 +350,9 @@ describe('workspace_panel', () => { // allows initial render expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); expect(isSaveable()).toBe(true); act(() => { @@ -499,90 +530,6 @@ describe('workspace_panel', () => { }); }); - it('should include data fetching for each layer in the expression', async () => { - const mockDatasource2 = createMockDatasource('a'); - const framePublicAPI = createMockFramePublicAPI(); - framePublicAPI.datasourceLayers = { - first: mockDatasource.publicAPIMock, - second: mockDatasource2.publicAPIMock, - }; - mockDatasource.toExpression.mockReturnValue('datasource'); - mockDatasource.getLayers.mockReturnValue(['first']); - - mockDatasource2.toExpression.mockReturnValue('datasource2'); - mockDatasource2.getLayers.mockReturnValue(['second', 'third']); - - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - />, - - { - preloadedState: { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - mock2: { - state: {}, - isLoading: false, - }, - }, - }, - } - ); - instance = mounted.instance; - instance.update(); - - const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string); - - expect(ast.chain[1].arguments.layerIds).toEqual(['first', 'second', 'third']); - expect(ast.chain[1].arguments.tables).toMatchInlineSnapshot(` - Array [ - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource2", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource2", - "type": "function", - }, - ], - "type": "expression", - }, - ] - `); - }); - it('should run the expression again if the date range changes', async () => { const framePublicAPI = createMockFramePublicAPI(); framePublicAPI.datasourceLayers = { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 56a24458bbdc0..d706b7d484c63 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -51,6 +51,7 @@ import type { ThemeServiceStart, } from '@kbn/core/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; import { Document } from '../persistence'; import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; import { @@ -58,8 +59,6 @@ import { isLensFilterEvent, isLensEditEvent, isLensTableRowContextMenuClickEvent, - LensBrushEvent, - LensFilterEvent, LensTableRowContextMenuEvent, VisualizationMap, Visualization, @@ -94,9 +93,9 @@ interface LensBaseEmbeddableInput extends EmbeddableInput { renderMode?: RenderMode; style?: React.CSSProperties; className?: string; - onBrushEnd?: (data: LensBrushEvent['data']) => void; + onBrushEnd?: (data: BrushTriggerEvent['data']) => void; onLoad?: (isLoading: boolean) => void; - onFilter?: (data: LensFilterEvent['data']) => void; + onFilter?: (data: ClickTriggerEvent['data']) => void; onTableRowClick?: (data: LensTableRowContextMenuEvent['data']) => void; } diff --git a/x-pack/plugins/lens/public/expressions.ts b/x-pack/plugins/lens/public/expressions.ts index ba8c07078a208..a52d835e3f002 100644 --- a/x-pack/plugins/lens/public/expressions.ts +++ b/x-pack/plugins/lens/public/expressions.ts @@ -8,22 +8,17 @@ import type { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import { getDatatable } from '../common/expressions/datatable/datatable'; import { datatableColumn } from '../common/expressions/datatable/datatable_column'; -import { mergeTables } from '../common/expressions/merge_tables'; import { renameColumns } from '../common/expressions/rename_columns/rename_columns'; import { formatColumn } from '../common/expressions/format_column'; import { counterRate } from '../common/expressions/counter_rate'; import { getTimeScale } from '../common/expressions/time_scale/time_scale'; -import { lensMultitable } from '../common/expressions'; export const setupExpressions = ( expressions: ExpressionsSetup, formatFactory: Parameters[0], getTimeZone: Parameters[0] ) => { - [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); - [ - mergeTables, counterRate, formatColumn, renameColumns, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx index a8d94b743adf8..96f4ef7daf89b 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/toolbar_component.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { memo } from 'react'; +import React, { memo, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, IconType } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import type { VisualizationToolbarProps } from '../types'; import { LegendSettingsPopover, @@ -49,6 +50,11 @@ export const HeatmapToolbar = memo( state, frame.datasourceLayers ).truncateText; + + const legendSize = state?.legend.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + return ( @@ -112,16 +118,17 @@ export const HeatmapToolbar = memo( legend: { ...state.legend, shouldTruncate: !current }, }); }} - legendSize={state?.legend.legendSize} - onLegendSizeChange={(legendSize) => { + legendSize={legendSize} + onLegendSizeChange={(newLegendSize) => { setState({ ...state, legend: { ...state.legend, - legendSize, + legendSize: newLegendSize, }, }); }} + showAutoLegendSizeOption={hadAutoLegendSize} />
diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 23b484f4cfd13..46c86d8c0adb0 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -295,8 +295,14 @@ export const getHeatmapVisualization = ({ } }, - toExpression(state, datasourceLayers, attributes): Ast | null { + toExpression( + state, + datasourceLayers, + attributes, + datasourceExpressionsByLayers = {} + ): Ast | null { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order @@ -304,9 +310,11 @@ export const getHeatmapVisualization = ({ if (!originalOrder || !state.valueAccessor) { return null; } + return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: FUNCTION_NAME, @@ -382,8 +390,9 @@ export const getHeatmapVisualization = ({ }; }, - toPreviewExpression(state, datasourceLayers): Ast | null { + toPreviewExpression(state, datasourceLayers, datasourceExpressionsByLayers = {}): Ast | null { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order @@ -395,6 +404,7 @@ export const getHeatmapVisualization = ({ return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: FUNCTION_NAME, diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index b8d00e7ff61b8..edf57ba703a2e 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -87,7 +87,6 @@ export type { IconPosition, ExtendedYConfigResult, DataLayerArgs, - LensMultiTable, ValueLabelMode, AxisExtentMode, DataLayerConfig, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index db10c420b90de..6806b1ce47795 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -383,6 +383,11 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` Object { "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, Object { "arguments": Object { "aggs": Array [ @@ -552,7 +557,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); }); it('should pass time shift parameter to metric agg functions', async () => { @@ -589,7 +594,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect((ast.chain[0].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); + expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); }); it('should wrap filtered metrics in filtered metric aggregation', async () => { @@ -638,7 +643,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.aggs[0]).toMatchInlineSnapshot(` + expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(` Object { "chain": Array [ Object { @@ -898,8 +903,8 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.metricsAtAllLevels).toEqual([false]); - expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]); + expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({ 'col-0-0': expect.objectContaining({ id: 'bucket1' }), 'col-1-1': expect.objectContaining({ id: 'bucket2' }), 'col-2-2': expect.objectContaining({ id: 'metric' }), @@ -939,8 +944,8 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); - expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[1].arguments.timeFields).not.toContain('timefield'); }); describe('references', () => { @@ -988,7 +993,7 @@ describe('IndexPattern Data Source', () => { const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; // @ts-expect-error we can't isolate just the reference type expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); - expect(ast.chain[2]).toEqual('mock'); + expect(ast.chain[3]).toEqual('mock'); }); it('should keep correct column mapping keys with reference columns present', async () => { @@ -1021,7 +1026,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({ 'col-0-0': expect.objectContaining({ id: 'col1', }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index aee1abd34e7cb..0307e748ac1fb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -277,6 +277,7 @@ function getExpressionForLayer( return { type: 'expression', chain: [ + { type: 'function', function: 'kibana', arguments: {} }, buildExpressionFunction('esaggs', { index: buildExpression([ buildExpressionFunction( diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 62c607f69265e..786d5b588baef 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -15,6 +15,7 @@ import { PaletteOutput, PaletteRegistry, CUSTOM_PALETTE, shiftPalette } from '@k import { ThemeServiceStart } from '@kbn/core/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { ColorMode, CustomPaletteState } from '@kbn/charts-plugin/common'; +import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; import { getSuggestions } from './metric_suggestions'; import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourceLayers } from '../types'; @@ -49,13 +50,15 @@ const toExpression = ( paletteService: PaletteRegistry, state: MetricState, datasourceLayers: DatasourceLayers, - attributes?: Partial> + attributes?: Partial>, + datasourceExpressionsByLayers: Record | undefined = {} ): Ast | null => { if (!state.accessor) { return null; } const [datasource] = Object.values(datasourceLayers); + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const operation = datasource && datasource.getOperationForColumnId(state.accessor); const stops = state.palette?.params?.stops || []; @@ -99,6 +102,7 @@ const toExpression = ( return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: 'metricVis', @@ -225,6 +229,7 @@ export const getMetricVisualization = ({ } ); }, + triggers: [VIS_EVENT_TO_TRIGGER.filter], getConfiguration(props) { const hasColoring = props.state.palette != null; @@ -271,10 +276,23 @@ export const getMetricVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => - toExpression(paletteService, state, datasourceLayers, { ...attributes }), - toPreviewExpression: (state, datasourceLayers) => - toExpression(paletteService, state, datasourceLayers, { mode: 'reduced' }), + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) => + toExpression( + paletteService, + state, + datasourceLayers, + { ...attributes }, + datasourceExpressionsByLayers + ), + + toPreviewExpression: (state, datasourceLayers, datasourceExpressionsByLayers) => + toExpression( + paletteService, + state, + datasourceLayers, + { mode: 'reduced' }, + datasourceExpressionsByLayers + ), setDimension({ prevState, columnId }) { return { ...prevState, accessor: columnId }; diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 9f506c7beb878..574f61bfc9232 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -134,20 +134,22 @@ const generateCommonArguments: GenerateExpressionAstArguments = ( layer, datasourceLayers, paletteService -) => ({ - labels: generateCommonLabelsAstArgs(state, attributes, layer), - buckets: operations.map((o) => o.columnId).map(prepareDimension), - metric: layer.metric ? [prepareDimension(layer.metric)] : [], - legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], - legendPosition: [layer.legendPosition || Position.Right], - maxLegendLines: [layer.legendMaxLines ?? 1], - legendSize: layer.legendSize ? [layer.legendSize] : [], - nestedLegend: [!!layer.nestedLegend], - truncateLegend: [ - layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, - ], - palette: generatePaletteAstArguments(paletteService, state.palette), -}); +) => { + return { + labels: generateCommonLabelsAstArgs(state, attributes, layer), + buckets: operations.map((o) => o.columnId).map(prepareDimension), + metric: layer.metric ? [prepareDimension(layer.metric)] : [], + legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], + legendPosition: [layer.legendPosition || Position.Right], + maxLegendLines: [layer.legendMaxLines ?? 1], + legendSize: layer.legendSize ? [layer.legendSize] : [], + nestedLegend: [!!layer.nestedLegend], + truncateLegend: [ + layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], + palette: generatePaletteAstArguments(paletteService, state.palette), + }; +}; const generatePieVisAst: GenerateExpressionAstFunction = (...rest) => ({ type: 'expression', @@ -245,7 +247,8 @@ function expressionHelper( state: PieVisualizationState, datasourceLayers: DatasourceLayers, paletteService: PaletteRegistry, - attributes: Attributes = { isPreview: false } + attributes: Attributes = { isPreview: false }, + datasourceExpressionsByLayers: Record ): Ast | null { const layer = state.layers[0]; const datasource = datasourceLayers[layer.layerId]; @@ -261,26 +264,55 @@ function expressionHelper( if (!layer.metric || !operations.length) { return null; } + const visualizationAst = generateExprAst( + state, + attributes, + operations, + layer, + datasourceLayers, + paletteService + ); - return generateExprAst(state, attributes, operations, layer, datasourceLayers, paletteService); + const datasourceAst = datasourceExpressionsByLayers[layer.layerId]; + return { + type: 'expression', + chain: [ + ...(datasourceAst ? datasourceAst.chain : []), + ...(visualizationAst ? visualizationAst.chain : []), + ], + }; } export function toExpression( state: PieVisualizationState, datasourceLayers: DatasourceLayers, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + datasourceExpressionsByLayers: Record | undefined = {} ) { - return expressionHelper(state, datasourceLayers, paletteService, { - ...attributes, - isPreview: false, - }); + return expressionHelper( + state, + datasourceLayers, + paletteService, + { + ...attributes, + isPreview: false, + }, + datasourceExpressionsByLayers + ); } export function toPreviewExpression( state: PieVisualizationState, datasourceLayers: DatasourceLayers, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + datasourceExpressionsByLayers: Record | undefined = {} ) { - return expressionHelper(state, datasourceLayers, paletteService, { isPreview: true }); + return expressionHelper( + state, + datasourceLayers, + paletteService, + { isPreview: true }, + datasourceExpressionsByLayers + ); } diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 67659d91ecefe..fefa01d708dc7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -6,7 +6,7 @@ */ import './toolbar.scss'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -18,6 +18,7 @@ import { } from '@elastic/eui'; import type { Position } from '@elastic/charts'; import type { PaletteRegistry } from '@kbn/coloring'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; @@ -73,6 +74,10 @@ export function PieToolbar(props: VisualizationToolbarProps legendSize === LegendSize.AUTO); + const onStateChange = useCallback( (part: Record) => { setState({ @@ -259,8 +264,9 @@ export function PieToolbar(props: VisualizationToolbarProps
); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 665fd5522c36f..927c67dce68b7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -241,9 +241,11 @@ export const getPieVisualization = ({ return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; }, - toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression: (state, layers, attributes, datasourceExpressionsByLayers) => + toExpression(state, layers, paletteService, attributes, datasourceExpressionsByLayers), + + toPreviewExpression: (state, layers, datasourceExpressionsByLayers) => + toPreviewExpression(state, layers, paletteService, datasourceExpressionsByLayers), renderToolbar(domElement, props) { render( diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 876cb63b0333d..e3c879d864a46 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -44,6 +44,7 @@ import { VISUALIZE_EDITOR_TRIGGER } from '@kbn/visualizations-plugin/public'; import { createStartServicesGetter } from '@kbn/kibana-utils-plugin/public'; import type { DiscoverSetup, DiscoverStart } from '@kbn/discover-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { AdvancedUiActionsSetup } from '@kbn/ui-actions-enhanced-plugin/public'; import type { EditorFrameService as EditorFrameServiceType } from './editor_frame_service'; import type { IndexPatternDatasource as IndexPatternDatasourceType, @@ -93,6 +94,7 @@ import type { SaveModalContainerProps } from './app_plugin/save_modal_container' import { setupExpressions } from './expressions'; import { getSearchProvider } from './search_provider'; +import { OpenInDiscoverDrilldown } from './trigger_actions/open_in_discover_drilldown'; export interface LensPluginSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -106,6 +108,7 @@ export interface LensPluginSetupDependencies { globalSearch?: GlobalSearchPluginSetup; usageCollection?: UsageCollectionSetup; discover?: DiscoverSetup; + uiActionsEnhanced: AdvancedUiActionsSetup; } export interface LensPluginStartDependencies { @@ -224,6 +227,7 @@ export class LensPlugin { private heatmapVisualization: HeatmapVisualizationType | undefined; private gaugeVisualization: GaugeVisualizationType | undefined; private topNavMenuEntries: LensTopNavMenuEntryGenerator[] = []; + private hasDiscoverAccess: boolean = false; private stopReportManager?: () => void; @@ -240,6 +244,8 @@ export class LensPlugin { eventAnnotation, globalSearch, usageCollection, + uiActionsEnhanced, + discover, }: LensPluginSetupDependencies ) { const startServices = createStartServicesGetter(core.getStartServices); @@ -285,6 +291,15 @@ export class LensPlugin { visualizations.registerAlias(getLensAliasConfig()); + if (discover) { + uiActionsEnhanced.registerDrilldown( + new OpenInDiscoverDrilldown({ + discover, + hasDiscoverAccess: () => this.hasDiscoverAccess, + }) + ); + } + setupExpressions( expressions, () => startServices().plugins.fieldFormats.deserialize, @@ -427,6 +442,7 @@ export class LensPlugin { } start(core: CoreStart, startDependencies: LensPluginStartDependencies): LensPublicStart { + this.hasDiscoverAccess = core.application.capabilities.discover.show as boolean; // unregisters the Visualize action and registers the lens one if (startDependencies.uiActions.hasAction(ACTION_VISUALIZE_FIELD)) { startDependencies.uiActions.unregisterAction(ACTION_VISUALIZE_FIELD); @@ -443,10 +459,7 @@ export class LensPlugin { startDependencies.uiActions.addTriggerAction( CONTEXT_MENU_TRIGGER, - createOpenInDiscoverAction( - startDependencies.discover!, - core.application.capabilities.discover.show as boolean - ) + createOpenInDiscoverAction(startDependencies.discover!, this.hasDiscoverAccess) ); return { diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx index fa7e12083435c..889c7697b8f68 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -9,7 +9,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import { useLegendAction } from '@elastic/charts'; -import type { LensFilterEvent } from '../types'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; export interface LegendActionPopoverProps { /** @@ -19,11 +19,11 @@ export interface LegendActionPopoverProps { /** * Callback on filter value */ - onFilter: (data: LensFilterEvent['data']) => void; + onFilter: (data: ClickTriggerEvent['data']) => void; /** * Determines the filter event data */ - context: LensFilterEvent['data']; + context: ClickTriggerEvent['data']; } export const LegendActionPopover: React.FunctionComponent = ({ diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index 9bf9a1885e6ac..777f2860cb8b6 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -36,6 +36,7 @@ describe('Legend Settings', () => { props = { legendOptions, mode: 'auto', + showAutoLegendSizeOption: true, onDisplayChange: jest.fn(), onPositionChange: jest.fn(), onLegendSizeChange: jest.fn(), diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index 944c55fb56091..e0dd990f0a99e 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; import { ToolbarButtonProps } from '@kbn/kibana-react-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { ToolbarPopover } from '.'; import { LegendLocationSettings } from './legend_location_settings'; import { ColumnsNumberSetting } from './columns_number_setting'; @@ -122,11 +123,16 @@ export interface LegendSettingsPopoverProps { /** * Legend size in pixels */ - legendSize?: number; + legendSize?: LegendSize; /** * Callback on legend size change */ - onLegendSizeChange: (size?: number) => void; + onLegendSizeChange: (size?: LegendSize) => void; + /** + * Whether to show auto legend size option. Should only be true for pre 8.3 visualizations that already had it as their setting. + * (We're trying to get people to stop using it so it can eventually be removed.) + */ + showAutoLegendSizeOption: boolean; } const DEFAULT_TRUNCATE_LINES = 1; @@ -185,6 +191,7 @@ export const LegendSettingsPopover: React.FunctionComponent {}, legendSize, onLegendSizeChange, + showAutoLegendSizeOption, }) => { return ( )} {location && ( diff --git a/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx new file mode 100644 index 0000000000000..9d5fa2cd8794c --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_size_settings.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { LegendSizeSettings } from './legend_size_settings'; +import { EuiSuperSelect } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/public'; + +describe('legend size settings', () => { + it('renders nothing if not vertical legend', () => { + const instance = shallow( + {}} + isVerticalLegend={false} + showAutoOption={false} + /> + ); + + expect(instance.html()).toBeNull(); + }); + + it('defaults to correct value', () => { + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={false} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe( + DEFAULT_LEGEND_SIZE.toString() + ); + }); + + it('reflects current setting in select', () => { + const CURRENT_SIZE = LegendSize.SMALL; + + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={false} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe(CURRENT_SIZE); + }); + + it('allows user to select a new option', () => { + const onSizeChange = jest.fn(); + + const instance = shallow( + + ); + + const onChange = instance.find(EuiSuperSelect).props().onChange; + + onChange(LegendSize.EXTRA_LARGE); + onChange(DEFAULT_LEGEND_SIZE); + + expect(onSizeChange).toHaveBeenNthCalledWith(1, LegendSize.EXTRA_LARGE); + expect(onSizeChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('hides "auto" option if visualization not using it', () => { + const getOptions = (showAutoOption: boolean) => + shallow( + {}} + isVerticalLegend={true} + showAutoOption={showAutoOption} + /> + ) + .find(EuiSuperSelect) + .props().options; + + const autoOption = expect.objectContaining({ value: LegendSize.AUTO }); + + expect(getOptions(true)).toContainEqual(autoOption); + expect(getOptions(false)).not.toContainEqual(autoOption); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx b/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx index 53da283de0b68..15e74f2601b2b 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_size_settings.tsx @@ -8,48 +8,36 @@ import React, { useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; - -export enum LegendSizes { - AUTO = '0', - SMALL = '80', - MEDIUM = '130', - LARGE = '180', - EXTRA_LARGE = '230', -} +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/public'; interface LegendSizeSettingsProps { - legendSize: number | undefined; - onLegendSizeChange: (size?: number) => void; + legendSize?: LegendSize; + onLegendSizeChange: (size?: LegendSize) => void; isVerticalLegend: boolean; + showAutoOption: boolean; } -const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ - { - value: LegendSizes.AUTO, - inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.auto', { - defaultMessage: 'Auto', - }), - }, +const legendSizeOptions: Array<{ value: LegendSize; inputDisplay: string }> = [ { - value: LegendSizes.SMALL, + value: LegendSize.SMALL, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.small', { defaultMessage: 'Small', }), }, { - value: LegendSizes.MEDIUM, + value: LegendSize.MEDIUM, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.medium', { defaultMessage: 'Medium', }), }, { - value: LegendSizes.LARGE, + value: LegendSize.LARGE, inputDisplay: i18n.translate('xpack.lens.shared.legendSizeSetting.legendSizeOptions.large', { defaultMessage: 'Large', }), }, { - value: LegendSizes.EXTRA_LARGE, + value: LegendSize.EXTRA_LARGE, inputDisplay: i18n.translate( 'xpack.lens.shared.legendSizeSetting.legendSizeOptions.extraLarge', { @@ -63,6 +51,7 @@ export const LegendSizeSettings = ({ legendSize, onLegendSizeChange, isVerticalLegend, + showAutoOption, }: LegendSizeSettingsProps) => { useEffect(() => { if (legendSize && !isVerticalLegend) { @@ -71,12 +60,27 @@ export const LegendSizeSettings = ({ }, [isVerticalLegend, legendSize, onLegendSizeChange]); const onLegendSizeOptionChange = useCallback( - (option) => onLegendSizeChange(Number(option) || undefined), + (option: LegendSize) => onLegendSizeChange(option === DEFAULT_LEGEND_SIZE ? undefined : option), [onLegendSizeChange] ); if (!isVerticalLegend) return null; + const options = showAutoOption + ? [ + { + value: LegendSize.AUTO, + inputDisplay: i18n.translate( + 'xpack.lens.shared.legendSizeSetting.legendSizeOptions.auto', + { + defaultMessage: 'Auto', + } + ), + }, + ...legendSizeOptions, + ] + : legendSizeOptions; + return ( diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts index 084bd65b70d31..eebdf04337f69 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.test.ts @@ -83,6 +83,7 @@ describe('open in discover action', () => { const embeddable = { getViewUnderlyingDataArgs: jest.fn(() => viewUnderlyingDataArgs), + type: 'lens', }; const discoverUrl = 'https://discover-redirect-url'; diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts index bd666f52bf0bc..54a24aac269b5 100644 --- a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_action.ts @@ -5,17 +5,23 @@ * 2.0. */ -import type { IEmbeddable } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { createAction } from '@kbn/ui-actions-plugin/public'; import type { DiscoverStart } from '@kbn/discover-plugin/public'; -import type { Embeddable } from '../embeddable'; -import { DOC_TYPE } from '../../common'; +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import { execute, isCompatible } from './open_in_discover_helpers'; const ACTION_OPEN_IN_DISCOVER = 'ACTION_OPEN_IN_DISCOVER'; -export const createOpenInDiscoverAction = (discover: DiscoverStart, hasDiscoverAccess: boolean) => - createAction<{ embeddable: IEmbeddable }>({ +interface Context { + embeddable: IEmbeddable; +} + +export const createOpenInDiscoverAction = ( + discover: Pick, + hasDiscoverAccess: boolean +) => + createAction({ type: ACTION_OPEN_IN_DISCOVER, id: ACTION_OPEN_IN_DISCOVER, order: 19, // right after Inspect which is 20 @@ -24,18 +30,10 @@ export const createOpenInDiscoverAction = (discover: DiscoverStart, hasDiscoverA i18n.translate('xpack.lens.app.exploreDataInDiscover', { defaultMessage: 'Explore data in Discover', }), - isCompatible: async (context: { embeddable: IEmbeddable }) => { - if (!hasDiscoverAccess) return false; - return ( - context.embeddable.type === DOC_TYPE && - (await (context.embeddable as Embeddable).canViewUnderlyingData()) - ); + isCompatible: async (context: Context) => { + return isCompatible({ hasDiscoverAccess, discover, embeddable: context.embeddable }); }, - execute: async (context: { embeddable: Embeddable }) => { - const args = context.embeddable.getViewUnderlyingDataArgs()!; - const discoverUrl = discover.locator?.getRedirectUrl({ - ...args, - }); - window.open(discoverUrl, '_blank'); + execute: async (context: Context) => { + return execute({ ...context, discover, hasDiscoverAccess }); }, }); diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx new file mode 100644 index 0000000000000..bd1fc948eb937 --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { FormEvent } from 'react'; +import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { execute, isCompatible } from './open_in_discover_helpers'; +import { mount } from 'enzyme'; +import { Filter } from '@kbn/es-query'; +import { + ActionFactoryContext, + CollectConfigProps, + OpenInDiscoverDrilldown, +} from './open_in_discover_drilldown'; + +jest.mock('./open_in_discover_helpers', () => ({ + isCompatible: jest.fn(() => true), + execute: jest.fn(), +})); + +describe('open in discover drilldown', () => { + let drilldown: OpenInDiscoverDrilldown; + beforeEach(() => { + drilldown = new OpenInDiscoverDrilldown({ + discover: {} as DiscoverSetup, + hasDiscoverAccess: () => true, + }); + }); + it('provides UI to edit config', () => { + const Component = (drilldown as unknown as { ReactCollectConfig: React.FC }) + .ReactCollectConfig; + const setConfig = jest.fn(); + const instance = mount( + + ); + instance.find('EuiSwitch').prop('onChange')!({} as unknown as FormEvent<{}>); + expect(setConfig).toHaveBeenCalledWith({ openInNewTab: true }); + }); + it('calls through to isCompatible helper', () => { + const filters: Filter[] = [{ meta: { disabled: false } }]; + drilldown.isCompatible( + { openInNewTab: true }, + { embeddable: { type: 'lens' } as IEmbeddable, filters } + ); + expect(isCompatible).toHaveBeenCalledWith(expect.objectContaining({ filters })); + }); + it('calls through to execute helper', () => { + const filters: Filter[] = [{ meta: { disabled: false } }]; + drilldown.execute( + { openInNewTab: true }, + { embeddable: { type: 'lens' } as IEmbeddable, filters } + ); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ filters, openInSameTab: false }) + ); + }); +}); diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx new file mode 100644 index 0000000000000..d957b9cafd4be --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_drilldown.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { IEmbeddable, EmbeddableInput } from '@kbn/embeddable-plugin/public'; +import { + Query, + Filter, + TimeRange, + extractTimeRange, + APPLY_FILTER_TRIGGER, +} from '@kbn/data-plugin/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '@kbn/kibana-utils-plugin/public'; +import { reactToUiComponent } from '@kbn/kibana-react-plugin/public'; +import { + UiActionsEnhancedDrilldownDefinition as Drilldown, + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, +} from '@kbn/ui-actions-enhanced-plugin/public'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { ApplyGlobalFilterActionContext } from '@kbn/unified-search-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { execute, isCompatible, isLensEmbeddable } from './open_in_discover_helpers'; + +interface EmbeddableQueryInput extends EmbeddableInput { + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; +} + +/** @internal */ +export type EmbeddableWithQueryInput = IEmbeddable; + +interface UrlDrilldownDeps { + discover: Pick; + hasDiscoverAccess: () => boolean; +} + +export type ActionContext = ApplyGlobalFilterActionContext; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type Config = { + openInNewTab: boolean; +}; + +export type OpenInDiscoverTrigger = typeof APPLY_FILTER_TRIGGER; + +export interface ActionFactoryContext extends BaseActionFactoryContext { + embeddable?: EmbeddableWithQueryInput; +} +export type CollectConfigProps = CollectConfigPropsBase; + +const OPEN_IN_DISCOVER_DRILLDOWN = 'OPEN_IN_DISCOVER_DRILLDOWN'; + +export class OpenInDiscoverDrilldown + implements Drilldown +{ + public readonly id = OPEN_IN_DISCOVER_DRILLDOWN; + + constructor(private readonly deps: UrlDrilldownDeps) {} + + public readonly order = 8; + + public readonly getDisplayName = () => + i18n.translate('xpack.lens.app.exploreDataInDiscoverDrilldown', { + defaultMessage: 'Open in Discover', + }); + + public readonly euiIcon = 'discoverApp'; + + supportedTriggers(): OpenInDiscoverTrigger[] { + return [APPLY_FILTER_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => { + return ( + + onConfig({ ...config, openInNewTab: !config.openInNewTab })} + data-test-subj="openInDiscoverDrilldownOpenInNewTab" + /> + + ); + }; + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + openInNewTab: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + return true; + }; + + public readonly isCompatible = async (config: Config, context: ActionContext) => { + return isCompatible({ + discover: this.deps.discover, + hasDiscoverAccess: this.deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable as IEmbeddable, + ...config, + }); + }; + + public readonly isConfigurable = (context: ActionFactoryContext) => { + return this.deps.hasDiscoverAccess() && isLensEmbeddable(context.embeddable as IEmbeddable); + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + const { restOfFilters: filters, timeRange: timeRange } = extractTimeRange( + context.filters, + context.timeFieldName + ); + execute({ + discover: this.deps.discover, + hasDiscoverAccess: this.deps.hasDiscoverAccess(), + ...context, + embeddable: context.embeddable as IEmbeddable, + openInSameTab: !config.openInNewTab, + filters, + timeRange, + }); + }; +} diff --git a/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts new file mode 100644 index 0000000000000..87f0931f1a3db --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/open_in_discover_helpers.ts @@ -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 type { DiscoverSetup } from '@kbn/discover-plugin/public'; +import { Filter } from '@kbn/es-query'; +import { TimeRange } from '@kbn/data-plugin/public'; +import { IEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { Embeddable } from '../embeddable'; +import { DOC_TYPE } from '../../common'; + +interface Context { + embeddable: IEmbeddable; + filters?: Filter[]; + timeRange?: TimeRange; + openInSameTab?: boolean; + hasDiscoverAccess: boolean; + discover: Pick; +} + +export function isLensEmbeddable(embeddable: IEmbeddable): embeddable is Embeddable { + return embeddable.type === DOC_TYPE; +} + +export async function isCompatible({ hasDiscoverAccess, embeddable }: Context) { + if (!hasDiscoverAccess) return false; + return isLensEmbeddable(embeddable) && (await embeddable.canViewUnderlyingData()); +} + +export function execute({ embeddable, discover, timeRange, filters, openInSameTab }: Context) { + if (!isLensEmbeddable(embeddable)) { + // shouldn't be executed because of the isCompatible check + throw new Error('Can only be executed in the context of Lens visualization'); + } + const args = embeddable.getViewUnderlyingDataArgs(); + if (!args) { + // shouldn't be executed because of the isCompatible check + throw new Error('Underlying data is not ready'); + } + const discoverUrl = discover.locator?.getRedirectUrl({ + ...args, + timeRange: timeRange || args.timeRange, + filters: [...(filters || []), ...args.filters], + }); + window.open(discoverUrl, !openInSameTab ? '_blank' : '_self'); +} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a91240e7e6a3e..1f2ee1266ddb7 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -23,12 +23,12 @@ import type { } from '@kbn/expressions-plugin/public'; import type { VisualizeEditorLayersContext } from '@kbn/visualizations-plugin/public'; import type { Query } from '@kbn/data-plugin/public'; -import type { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/public'; import type { UiActionsStart, RowClickContext, VisualizeFieldContext, } from '@kbn/ui-actions-plugin/public'; +import { ClickTriggerEvent, BrushTriggerEvent } from '@kbn/charts-plugin/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { @@ -931,22 +931,6 @@ export interface Visualization { * On Edit events the frame will call this to know what's going to be the next visualization state */ onEditAction?: (state: T, event: LensEditEvent) => T; - - /** - * `datasourceExpressionsByLayers` will be passed to the params of `toExpression` and `toPreviewExpression` - * functions and datasource expressions will not be appended to the expression automatically. - */ - shouldBuildDatasourceExpressionManually?: () => boolean; -} - -export interface LensFilterEvent { - name: 'filter'; - data: ValueClickContext['data']; -} - -export interface LensBrushEvent { - name: 'brush'; - data: RangeSelectContext['data']; } // Use same technique as TriggerContext @@ -975,11 +959,11 @@ export interface LensTableRowContextMenuEvent { data: RowClickContext['data']; } -export function isLensFilterEvent(event: ExpressionRendererEvent): event is LensFilterEvent { +export function isLensFilterEvent(event: ExpressionRendererEvent): event is ClickTriggerEvent { return event.name === 'filter'; } -export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensBrushEvent { +export function isLensBrushEvent(event: ExpressionRendererEvent): event is BrushTriggerEvent { return event.name === 'brush'; } @@ -991,7 +975,7 @@ export function isLensEditEvent( export function isLensTableRowContextMenuClickEvent( event: ExpressionRendererEvent -): event is LensBrushEvent { +): event is BrushTriggerEvent { return event.name === 'tableRowContextMenuClick'; } @@ -1003,8 +987,8 @@ export function isLensTableRowContextMenuClickEvent( export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { event: ( event: - | LensFilterEvent - | LensBrushEvent + | ClickTriggerEvent + | BrushTriggerEvent | LensEditEvent | LensTableRowContextMenuEvent ) => void; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 2a2bd0a35efa1..0b650ccbedbc0 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -12,15 +12,9 @@ import type { TimefilterContract } from '@kbn/data-plugin/public'; import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public'; import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; import { search } from '@kbn/data-plugin/public'; +import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; import type { Document } from './persistence/saved_object_store'; -import type { - Datasource, - DatasourceMap, - LensBrushEvent, - LensFilterEvent, - Visualization, - StateSetter, -} from './types'; +import type { Datasource, DatasourceMap, Visualization, StateSetter } from './types'; import type { DatasourceStates, VisualizationState } from './state_management'; export function getVisualizeGeoFieldMessage(fieldType: string) { @@ -153,7 +147,7 @@ export function getRemoveOperation( return layerCount === 1 ? 'clear' : 'remove'; } -export function inferTimeField(context: LensBrushEvent['data'] | LensFilterEvent['data']) { +export function inferTimeField(context: BrushTriggerEvent['data'] | ClickTriggerEvent['data']) { const tablesAndColumns = 'table' in context ? [{ table: context.table, column: context.column }] diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index 74f7d518e1166..e85ef81a5dd8c 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -113,9 +113,11 @@ const toExpression = ( paletteService: PaletteRegistry, state: GaugeVisualizationState, datasourceLayers: DatasourceLayers, - attributes?: Partial> + attributes?: Partial>, + datasourceExpressionsByLayers: Record | undefined = {} ): Ast | null => { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); if (!originalOrder || !state.metricAccessor) { @@ -125,6 +127,7 @@ const toExpression = ( return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: EXPRESSION_GAUGE_NAME, @@ -420,10 +423,17 @@ export const getGaugeVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => - toExpression(paletteService, state, datasourceLayers, { ...attributes }), - toPreviewExpression: (state, datasourceLayers) => - toExpression(paletteService, state, datasourceLayers), + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers = {}) => + toExpression( + paletteService, + state, + datasourceLayers, + { ...attributes }, + datasourceExpressionsByLayers + ), + + toPreviewExpression: (state, datasourceLayers, datasourceExpressionsByLayers = {}) => + toExpression(paletteService, state, datasourceLayers, undefined, datasourceExpressionsByLayers), getErrorMessages(state) { // not possible to break it? 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 9c4ee0d3b245f..afdfd8e200100 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 @@ -134,13 +134,7 @@ Object { ], "table": Array [ Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "kibana", - "type": "function", - }, - ], + "chain": Array [], "type": "expression", }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index a329c12b083a5..67febcd4b9d00 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -6,9 +6,10 @@ */ import { getColorAssignments } from './color_assignment'; -import type { FormatFactory, LensMultiTable } from '../../common'; +import type { FormatFactory } from '../../common'; import { layerTypes } from '../../common'; import { XYDataLayerConfig } from './types'; +import { Datatable } from '@kbn/expressions-plugin'; describe('color_assignment', () => { const layers: XYDataLayerConfig[] = [ @@ -30,8 +31,7 @@ describe('color_assignment', () => { }, ]; - const data: LensMultiTable = { - type: 'lens_multitable', + const data: { tables: Record } = { tables: { '1': { type: 'datatable', 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 1c73c455dfe9e..cf9a441fc6f53 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -10,8 +10,8 @@ import { ScaleType } from '@elastic/charts'; import type { PaletteRegistry } from '@kbn/coloring'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; -import type { AxisExtentConfig, ExtendedYConfig, YConfig } from '@kbn/expression-xy-plugin/common'; -import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; +import type { AxisExtentConfig, YConfig, ExtendedYConfig } from '@kbn/expression-xy-plugin/common'; import { State, XYDataLayerConfig, @@ -214,10 +214,11 @@ export const buildExpression = ( : [], position: !state.legend.isInside ? [state.legend.position] : [], isInside: state.legend.isInside ? [state.legend.isInside] : [], - legendSize: - !state.legend.isInside && state.legend.legendSize - ? [state.legend.legendSize] - : [], + legendSize: state.legend.isInside + ? [LegendSize.AUTO] + : state.legend.legendSize + ? [state.legend.legendSize] + : [], horizontalAlignment: state.legend.horizontalAlignment && state.legend.isInside ? [state.legend.horizontalAlignment] @@ -343,11 +344,6 @@ export const buildExpression = ( }; }; -const buildTableExpression = (datasourceExpression: Ast): ExpressionAstExpression => ({ - type: 'expression', - chain: [{ type: 'function', function: 'kibana', arguments: {} }, ...datasourceExpression.chain], -}); - const referenceLineLayerToExpression = ( layer: XYReferenceLineLayerConfig, datasourceLayer: DatasourcePublicAPI, @@ -368,7 +364,7 @@ const referenceLineLayerToExpression = ( : [], accessors: layer.accessors, columnToLabel: [JSON.stringify(getColumnToLabelMap(layer, datasourceLayer))], - ...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}), + ...(datasourceExpression ? { table: [datasourceExpression] } : {}), }, }, ], @@ -437,7 +433,7 @@ const dataLayerToExpression = ( seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], - ...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}), + ...(datasourceExpression ? { table: [datasourceExpression] } : {}), palette: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 97bac36a93465..6c3fe3dcaea7f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -514,8 +514,6 @@ export const getXyVisualization = ({ ); }, - shouldBuildDatasourceExpressionManually: () => true, - toExpression: (state, layers, attributes, datasourceExpressionsByLayers = {}) => toExpression( state, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 00c4e9c8eaeb2..bd39a61b08acd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { Position, ScaleType } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { AxesSettingsConfig, AxisExtentConfig } from '@kbn/expression-xy-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import type { VisualizationToolbarProps, FramePublicAPI } from '../../types'; import { State, XYState } from '../types'; import { isHorizontalChart } from '../state_helpers'; @@ -294,6 +295,10 @@ export const XyToolbar = memo(function XyToolbar( props.frame.datasourceLayers ).truncateText; + const legendSize = state.legend.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + return ( @@ -398,16 +403,17 @@ export const XyToolbar = memo(function XyToolbar( valuesInLegend: !state.valuesInLegend, }); }} - legendSize={state.legend.legendSize} - onLegendSizeChange={(legendSize) => { + legendSize={legendSize} + onLegendSizeChange={(newLegendSize) => { setState({ ...state, legend: { ...state.legend, - legendSize, + legendSize: newLegendSize, }, }); }} + showAutoLegendSizeOption={hadAutoLegendSize} />
diff --git a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts index 215f080d3dbdf..dc3933d852979 100644 --- a/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts +++ b/x-pack/plugins/lens/server/embeddable/make_lens_embeddable_factory.ts @@ -14,6 +14,7 @@ import { import { DOC_TYPE } from '../../common'; import { commonEnhanceTableRowHeight, + commonPreserveOldLegendSizeDefault, commonFixValueLabelsInXY, commonLockOldMetricVisSettings, commonMakeReversePaletteAsCustom, @@ -35,7 +36,6 @@ import { LensDocShapePre712, VisState716, VisState810, - VisState820, VisStatePre715, VisStatePre830, } from '../migrations/types'; @@ -113,8 +113,9 @@ export const makeLensEmbeddableFactory = } as unknown as SerializableRecord; }, '8.3.0': (state) => { - const lensState = state as unknown as { attributes: LensDocShape810 }; + const lensState = state as unknown as { attributes: LensDocShape810 }; let migratedLensState = commonLockOldMetricVisSettings(lensState.attributes); + migratedLensState = commonPreserveOldLegendSizeDefault(migratedLensState); migratedLensState = commonFixValueLabelsInXY( migratedLensState as LensDocShape810 ); diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index b124a9cbaf33b..d5bb5587b982e 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -13,7 +13,6 @@ import { renameColumns, getTimeScale, getDatatable, - lensMultitable, } from '../../common/expressions'; import { getFormatFactory, getTimeZoneFactory } from './utils'; @@ -23,8 +22,6 @@ export const setupExpressions = ( core: CoreSetup, expressions: ExpressionsServerSetup ) => { - [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); - [ counterRate, formatColumn, diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts index 7cafa41f569d4..10fc9b25e6f34 100644 --- a/x-pack/plugins/lens/server/migrations/common_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -255,6 +255,38 @@ export const commonLockOldMetricVisSettings = ( return newAttributes as LensDocShape830; }; +export const commonPreserveOldLegendSizeDefault = ( + attributes: LensDocShape810 +): LensDocShape830 => { + const newAttributes = cloneDeep(attributes); + + const pixelsToLegendSize: Record = { + undefined: 'auto', + '80': 'small', + '130': 'medium', + '180': 'large', + '230': 'xlarge', + }; + + if (['lnsXY', 'lnsHeatmap'].includes(newAttributes.visualizationType + '')) { + const legendConfig = (newAttributes.state.visualization as { legend: { legendSize: number } }) + .legend; + (legendConfig.legendSize as unknown as string) = + pixelsToLegendSize[String(legendConfig.legendSize)]; + } + + if (newAttributes.visualizationType === 'lnsPie') { + const layers = (newAttributes.state.visualization as { layers: Array<{ legendSize: number }> }) + .layers; + + layers.forEach((layer) => { + (layer.legendSize as unknown as string) = pixelsToLegendSize[String(layer.legendSize)]; + }); + } + + return newAttributes as LensDocShape830; +}; + const getApplyCustomVisualizationMigrationToLens = (id: string, migration: MigrateFunction) => { return (savedObject: { attributes: LensDocShape }) => { if (savedObject.attributes.visualizationType !== id) return savedObject; diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index af68c5020e420..d43d4c4cb2a38 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -2065,6 +2065,7 @@ describe('Lens migrations', () => { expect(layer2Columns['4'].params).toHaveProperty('includeEmptyRows', true); }); }); + describe('8.3.0 old metric visualization defaults', () => { const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; const example = { @@ -2115,6 +2116,74 @@ describe('Lens migrations', () => { }); }); + describe('8.3.0 - convert legend sizes to strings', () => { + const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; + const migrate = migrations['8.3.0']; + + const autoLegendSize = 'auto'; + const largeLegendSize = 'large'; + const largeLegendSizePx = 180; + + it('works for XY visualization and heatmap', () => { + const getDoc = (type: string, legendSize: number | undefined) => + ({ + attributes: { + visualizationType: type, + state: { + visualization: { + legend: { + legendSize, + }, + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc); + + expect( + migrate(getDoc('lnsXY', undefined), context).attributes.state.visualization.legend + .legendSize + ).toBe(autoLegendSize); + expect( + migrate(getDoc('lnsXY', largeLegendSizePx), context).attributes.state.visualization.legend + .legendSize + ).toBe(largeLegendSize); + + expect( + migrate(getDoc('lnsHeatmap', undefined), context).attributes.state.visualization.legend + .legendSize + ).toBe(autoLegendSize); + expect( + migrate(getDoc('lnsHeatmap', largeLegendSizePx), context).attributes.state.visualization + .legend.legendSize + ).toBe(largeLegendSize); + }); + + it('works for pie visualization', () => { + const pieVisDoc = { + attributes: { + visualizationType: 'lnsPie', + state: { + visualization: { + layers: [ + { + legendSize: undefined, + }, + { + legendSize: largeLegendSizePx, + }, + ], + }, + }, + }, + } as unknown as SavedObjectUnsanitizedDoc; + + expect(migrate(pieVisDoc, context).attributes.state.visualization.layers).toEqual([ + { legendSize: autoLegendSize }, + { legendSize: largeLegendSize }, + ]); + }); + }); + describe('8.3.0 valueLabels in XY', () => { const context = { log: { warning: () => {} } } as unknown as SavedObjectMigrationContext; const example = { @@ -2128,6 +2197,7 @@ describe('Lens migrations', () => { state: { visualization: { valueLabels: 'inside', + legend: {}, }, }, }, diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 00ec6c29154e3..3870bab9fad65 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -49,6 +49,7 @@ import { commonSetIncludeEmptyRowsDateHistogram, commonFixValueLabelsInXY, commonLockOldMetricVisSettings, + commonPreserveOldLegendSizeDefault, } from './common_migrations'; interface LensDocShapePre710 { @@ -505,6 +506,10 @@ const lockOldMetricVisSettings: SavedObjectMigrationFn ({ ...doc, attributes: commonLockOldMetricVisSettings(doc.attributes) }); +const preserveOldLegendSizeDefault: SavedObjectMigrationFn = ( + doc +) => ({ ...doc, attributes: commonPreserveOldLegendSizeDefault(doc.attributes) }); + const lensMigrations: SavedObjectMigrationMap = { '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs @@ -524,7 +529,7 @@ const lensMigrations: SavedObjectMigrationMap = { setIncludeEmptyRowsDateHistogram, enhanceTableRowHeight ), - '8.3.0': flow(lockOldMetricVisSettings, fixValueLabelsInXY), + '8.3.0': flow(lockOldMetricVisSettings, preserveOldLegendSizeDefault, fixValueLabelsInXY), }; export const getAllMigrations = ( diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index e00581833f621..20def97df7aed 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../../src/core/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../global_search/tsconfig.json" }, + { "path": "../ui_actions_enhanced/tsconfig.json" }, { "path": "../saved_objects_tagging/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/data_views/tsconfig.json" }, diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 4c4ca64f7ac07..edbf4df979f7b 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -25,7 +25,8 @@ "mapsEms", "savedObjects", "share", - "presentationUtil" + "presentationUtil", + "screenshotMode" ], "optionalPlugins": [ "cloud", diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx index 5089a8cc6c8d5..467c2ce92ff6e 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx @@ -45,12 +45,10 @@ export function ChoroplethChart({ return null; } - const table = data.tables[args.layerId]; - let emsLayerId = args.emsLayerId ? args.emsLayerId : emsWorldLayerId; let emsField = args.emsField ? args.emsField : 'iso2'; if (!args.emsLayerId || !args.emsField) { - const emsSuggestion = getEmsSuggestion(emsFileLayers, table, args.regionAccessor); + const emsSuggestion = getEmsSuggestion(emsFileLayers, data, args.regionAccessor); if (emsSuggestion) { emsLayerId = emsSuggestion.layerId; emsField = emsSuggestion.field; @@ -66,7 +64,7 @@ export function ChoroplethChart({ defaultMessage: '{emsLayerLabel} by {accessorLabel}', values: { emsLayerLabel, - accessorLabel: getAccessorLabel(table, args.valueAccessor), + accessorLabel: getAccessorLabel(data, args.valueAccessor), }, }) : '', @@ -76,16 +74,16 @@ export function ChoroplethChart({ right: { id: args.valueAccessor, type: SOURCE_TYPES.TABLE_SOURCE, - __rows: table.rows, + __rows: data.rows, __columns: [ { name: args.regionAccessor, - label: getAccessorLabel(table, args.regionAccessor), + label: getAccessorLabel(data, args.regionAccessor), type: 'string', }, { name: args.valueAccessor, - label: getAccessorLabel(table, args.valueAccessor), + label: getAccessorLabel(data, args.valueAccessor), type: 'number', }, ], diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts index 7ed1ddfbd4381..989cc06c5d53b 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts @@ -6,8 +6,8 @@ */ import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { i18n } from '@kbn/i18n'; -import type { LensMultiTable } from '@kbn/lens-plugin/common'; import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; import type { ChoroplethChartConfig, ChoroplethChartProps } from './types'; import { RENDERER_ID } from './expression_renderer'; @@ -20,7 +20,7 @@ interface ChoroplethChartRender { export const getExpressionFunction = (): ExpressionFunctionDefinition< 'lens_choropleth_chart', - LensMultiTable, + Datatable, Omit, ChoroplethChartRender > => ({ @@ -57,11 +57,14 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< help: 'Value accessor identifies the value column', }, }, - inputTypes: ['lens_multitable'], + inputTypes: ['datatable'], fn(data, args, handlers) { if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( - Object.values(data.tables)[0], + data, [ [ args.valueAccessor ? [args.valueAccessor] : undefined, @@ -88,6 +91,6 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< data, args, }, - } as ChoroplethChartRender; + }; }, }); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts index 79c05a93ef2d4..7dc9a16056e77 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { LensMultiTable } from '@kbn/lens-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; export interface ChoroplethChartState { layerId: string; @@ -21,6 +21,6 @@ export interface ChoroplethChartConfig extends ChoroplethChartState { } export interface ChoroplethChartProps { - data: LensMultiTable; + data: Datatable; args: ChoroplethChartConfig; } diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx index cbac26f220163..54f459c3f7b38 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx @@ -138,14 +138,16 @@ export const getVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => { + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers = {}) => { if (!state.regionAccessor || !state.valueAccessor) { return null; } + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; return { type: 'expression', chain: [ + ...(datasourceExpression ? datasourceExpression.chain : []), { type: 'function', function: 'lens_choropleth_chart', diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 5e9662c543641..b5b232aeeaae6 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -41,6 +41,7 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LensPublicSetup } from '@kbn/lens-plugin/public'; +import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; import { createRegionMapFn, regionMapRenderer, @@ -88,6 +89,7 @@ export interface MapsPluginSetupDependencies { share: SharePluginSetup; licensing: LicensingPluginSetup; usageCollection?: UsageCollectionSetup; + screenshotMode: ScreenshotModePluginSetup; } export interface MapsPluginStartDependencies { @@ -144,7 +146,15 @@ export class MapsPlugin registerLicensedFeatures(plugins.licensing); const config = this._initializerContext.config.get(); - setMapAppConfig(config); + setMapAppConfig({ + ...config, + + // Override this when we know we are taking a screenshot (i.e. no user interaction) + // to avoid a blank-canvas issue when rendering maps on a PDF + preserveDrawingBuffer: plugins.screenshotMode.isScreenshotMode() + ? true + : config.preserveDrawingBuffer, + }); const locator = plugins.share.url.locators.create( new MapsAppLocatorDefinition({ diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index 5d5f4223fab9a..57cc09dec4b16 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -33,6 +33,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/shared_ux/tsconfig.json" }, + { "path": "../../../src/plugins/screenshot_mode/tsconfig.json" }, { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index fd00058e64713..13f7bb58a0f44 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -99,6 +99,14 @@ export function extractJobDetails(job, basePath, refreshJobList) { return ['', ]; }), }; + if (job.alerting_rules) { + // remove the alerting_rules list from the general section + // so not to show it twice. + const i = general.items.findIndex((item) => item[0] === 'alerting_rules'); + if (i >= 0) { + general.items.splice(i, 1); + } + } const detectors = { id: 'detectors', diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index aefdf0ce6e431..d15c500ddb9c4 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -47,6 +47,10 @@ export interface InferenceStatsResponse { trained_model_stats: TrainedModelStat[]; } +export interface MlInferTrainedModelDeploymentResponse { + inference_results: estypes.MlInferTrainedModelDeploymentResponse[]; +} + /** * Service with APIs calls to perform inference operations. * @param httpService @@ -143,22 +147,13 @@ export function trainedModelsApiProvider(httpService: HttpService) { inferTrainedModel(modelId: string, payload: any, timeout?: string) { const body = JSON.stringify(payload); - return httpService.http({ + return httpService.http({ path: `${apiBasePath}/trained_models/infer/${modelId}`, method: 'POST', body, ...(timeout ? { query: { timeout } as HttpFetchQuery } : {}), }); }, - - ingestPipelineSimulate(payload: estypes.IngestSimulateRequest['body']) { - const body = JSON.stringify(payload); - return httpService.http({ - path: `${apiBasePath}/trained_models/ingest_pipeline_simulate`, - method: 'POST', - body, - }); - }, }; } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index 357015b057996..992b217a0b2df 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -53,7 +53,7 @@ import { DEPLOYMENT_STATE, TRAINED_MODEL_TYPE } from '../../../../common/constan import { getUserConfirmationProvider } from './force_stop_dialog'; import { MLSavedObjectsSpacesList } from '../../components/ml_saved_objects_spaces_list'; import { SavedObjectsWarning } from '../../components/saved_objects_warning'; -import { TestTrainedModelFlyout, isTestable } from './test_models'; +import { TestTrainedModelFlyout, isTestable, isTestEnabled } from './test_models'; type Stats = Omit; @@ -184,10 +184,8 @@ export const ModelsList: FC = ({ } } - // Need to fetch state for 3rd party models to enable/disable actions - await fetchModelsStats( - newItems.filter((v) => v.model_type.includes(TRAINED_MODEL_TYPE.PYTORCH)) - ); + // Need to fetch state for all models to enable/disable actions + await fetchModelsStats(newItems); setItems(newItems); @@ -484,9 +482,7 @@ export const ModelsList: FC = ({ isPrimary: true, available: isTestable, onClick: setShowTestFlyout, - enabled: (item) => - isPopulatedObject(item.stats?.deployment_stats) && - item.stats?.deployment_stats?.state === DEPLOYMENT_STATE.STARTED, + enabled: isTestEnabled, }, ] as Array>) ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts index da7c12c1c0c58..533d8e2315a9e 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts @@ -6,4 +6,4 @@ */ export { TestTrainedModelFlyout } from './test_flyout'; -export { isTestable } from './utils'; +export { isTestable, isTestEnabled } from './utils'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts index ef46c223f609e..c6cde8da39469 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts @@ -10,9 +10,9 @@ import { TextClassificationInference, ZeroShotClassificationInference, FillMaskInference, + LangIdentInference, } from './text_classification'; import { TextEmbeddingInference } from './text_embedding'; -import { LangIdentInference } from './lang_ident'; export type InferrerType = | NerInference diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts index db27fb96e9c2a..e3b502a10f6ce 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts @@ -9,8 +9,13 @@ import { BehaviorSubject } from 'rxjs'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { MLHttpFetchError } from '../../../../../../common/util/errors'; +import { SupportedPytorchTasksType } from '../../../../../../common/constants/trained_models'; import { trainedModelsApiProvider } from '../../../../services/ml_api_service/trained_models'; +export type InferenceType = + | SupportedPytorchTasksType + | keyof estypes.AggregationsInferenceConfigContainer; + const DEFAULT_INPUT_FIELD = 'text_field'; export type FormattedNerResponse = Array<{ @@ -32,6 +37,7 @@ export enum RUNNING_STATE { } export abstract class InferenceBase { + protected abstract inferenceType: InferenceType; protected readonly inputField: string; public inputText$ = new BehaviorSubject(''); public inferenceResult$ = new BehaviorSubject(null); @@ -67,4 +73,27 @@ export abstract class InferenceBase { protected abstract getOutputComponent(): JSX.Element; protected abstract infer(): Promise; + + protected getInferenceConfig(): estypes.AggregationsClassificationInferenceOptions | undefined { + return this.model.inference_config[ + this.inferenceType as keyof estypes.AggregationsInferenceConfigContainer + ]; + } + + protected getNumTopClassesConfig(defaultOverride = 5) { + const options: estypes.AggregationsClassificationInferenceOptions | undefined = + this.getInferenceConfig(); + + if (options?.num_top_classes !== undefined && options?.num_top_classes > 0) { + return {}; + } + + return { + inference_config: { + [this.inferenceType]: { + num_top_classes: defaultOverride, + }, + }, + }; + } } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts deleted file mode 100644 index 34893d7dc9402..0000000000000 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts +++ /dev/null @@ -1,92 +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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -import { InferenceBase, InferResponse } from '../inference_base'; -import { getGeneralInputComponent } from '../text_input'; -import { getLangIdentOutputComponent } from './lang_ident_output'; - -export type FormattedLangIdentResponse = Array<{ - className: string; - classProbability: number; - classScore: number; -}>; - -export type LangIdentResponse = InferResponse< - FormattedLangIdentResponse, - estypes.IngestSimulateResponse ->; - -export class LangIdentInference extends InferenceBase { - public async infer() { - try { - this.setRunning(); - const inputText = this.inputText$.value; - const payload: estypes.IngestSimulateRequest['body'] = { - pipeline: { - processors: [ - { - inference: { - model_id: this.model.model_id, - inference_config: { - // @ts-expect-error classification missing from type - classification: { - num_top_classes: 3, - }, - }, - field_mappings: { - contents: this.inputField, - }, - target_field: '_ml.lang_ident', - }, - }, - ], - }, - docs: [ - { - _source: { - contents: inputText, - }, - }, - ], - }; - const resp = await this.trainedModelsApi.ingestPipelineSimulate(payload); - if (resp.docs.length) { - const topClasses = resp.docs[0].doc?._source._ml?.lang_ident?.top_classes ?? []; - - const r: LangIdentResponse = { - response: topClasses.map((t: estypes.MlTopClassEntry) => ({ - className: t.class_name, - classProbability: t.class_probability, - classScore: t.class_score, - })), - rawResponse: resp, - inputText, - }; - this.inferenceResult$.next(r); - this.setFinished(); - return r; - } - const r: LangIdentResponse = { response: [], rawResponse: resp, inputText }; - this.inferenceResult$.next(r); - this.setFinished(); - return r; - } catch (error) { - this.setFinishedWithErrors(error); - throw error; - } - } - - public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); - } - - public getOutputComponent(): JSX.Element { - return getLangIdentOutputComponent(this); - } -} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts index 4125dcd02c6db..13f07d8c88770 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts @@ -10,6 +10,8 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getNerOutputComponent } from './ner_output'; +import { MlInferTrainedModelDeploymentResponse } from '../../../../../services/ml_api_service/trained_models'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export type FormattedNerResponse = Array<{ value: string; @@ -18,15 +20,17 @@ export type FormattedNerResponse = Array<{ export type NerResponse = InferResponse< FormattedNerResponse, - estypes.MlInferTrainedModelDeploymentResponse + MlInferTrainedModelDeploymentResponse >; export class NerInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.NER; + public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; - const payload = { docs: { [this.inputField]: inputText } }; + const inputText = this.inputText$.getValue(); + const payload = { docs: [{ [this.inputField]: inputText }] }; const resp = await this.trainedModelsApi.inferTrainedModel( this.model.model_id, payload, @@ -56,8 +60,8 @@ export class NerInference extends InferenceBase { } } -function parseResponse(resp: estypes.MlInferTrainedModelDeploymentResponse): FormattedNerResponse { - const { predicted_value: predictedValue, entities } = resp; +function parseResponse(resp: MlInferTrainedModelDeploymentResponse): FormattedNerResponse { + const [{ predicted_value: predictedValue, entities }] = resp.inference_results; const splitWordsAndEntitiesRegex = /(\[.*?\]\(.*?&.*?\))/; const matchEntityRegex = /(\[.*?\])\((.*?)&(.*?)\)/; if (predictedValue === undefined || entities === undefined) { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx index 0031aafa81443..4a82dcb82aa65 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx @@ -14,14 +14,9 @@ import type { InferrerType } from '.'; import { NerResponse } from './ner'; import { TextClassificationResponse } from './text_classification'; import { TextEmbeddingResponse } from './text_embedding'; -import { LangIdentResponse } from './lang_ident'; import { RUNNING_STATE } from './inference_base'; -type InferenceResponse = - | LangIdentResponse - | NerResponse - | TextClassificationResponse - | TextEmbeddingResponse; +type InferenceResponse = NerResponse | TextClassificationResponse | TextEmbeddingResponse; export const RawOutput: FC<{ inferrer: InferrerType; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts index d360711995f98..ab136900c7d1e 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts @@ -10,12 +10,14 @@ import { InferResponse } from '../inference_base'; const PROBABILITY_SIG_FIGS = 3; export interface RawTextClassificationResponse { - predicted_value: string; - prediction_probability: number; - top_classes?: Array<{ - class_name: string; - class_probability: number; - class_score: number; + inference_results: Array<{ + predicted_value: string; + prediction_probability: number; + top_classes?: Array<{ + class_name: string; + class_probability: number; + class_score: number; + }>; }>; } @@ -34,21 +36,24 @@ export function processResponse( model: estypes.MlTrainedModelConfig, inputText: string ): TextClassificationResponse { + const { + inference_results: [inferenceResults], + } = resp; const labels: string[] = // @ts-expect-error inference config is wrong model.inference_config.text_classification?.classification_labels ?? []; let formattedResponse = [ { - value: resp.predicted_value, - predictionProbability: resp.prediction_probability, + value: inferenceResults.predicted_value, + predictionProbability: inferenceResults.prediction_probability, }, ]; - if (resp.top_classes !== undefined) { + if (inferenceResults.top_classes !== undefined) { // if num_top_classes has been specified in the model, // base the returned results on this list - formattedResponse = resp.top_classes.map((topClass) => { + formattedResponse = inferenceResults.top_classes.map((topClass) => { return { value: topClass.class_name, predictionProbability: topClass.class_probability, @@ -59,9 +64,9 @@ export function processResponse( // we can safely assume the non-top value and return two results formattedResponse = labels.map((value) => { const predictionProbability = - resp.predicted_value === value - ? resp.prediction_probability - : 1 - resp.prediction_probability; + inferenceResults.predicted_value === value + ? inferenceResults.prediction_probability + : 1 - inferenceResults.prediction_probability; return { value, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts index c8c993785dac2..bb4feaffffb38 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts @@ -11,20 +11,20 @@ import type { TextClassificationResponse, RawTextClassificationResponse } from ' import { processResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; import { getFillMaskOutputComponent } from './fill_mask_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; const MASK = '[MASK]'; export class FillMaskInference extends InferenceBase { - // @ts-expect-error model type is wrong - private numTopClasses = this.model.inference_config?.fill_mask?.num_top_classes || 5; + protected inferenceType = SUPPORTED_PYTORCH_TASKS.FILL_MASK; public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, - inference_config: { fill_mask: { num_top_classes: this.numTopClasses } }, + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx index dee08392aad09..62a5a957f8a38 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx @@ -7,9 +7,10 @@ import React, { FC, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiProgress, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import type { FillMaskInference } from './fill_mask_inference'; +import { TextClassificationOutput } from './text_classification_output'; export const getFillMaskOutputComponent = (inferrer: FillMaskInference) => ( @@ -32,20 +33,7 @@ const FillMaskOutput: FC<{ - - {result.response.map(({ value, predictionProbability }) => ( - <> - - - - <> - {value} - {predictionProbability} - - - - - ))} + ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts index 4eeef37519ff2..5274333a235cd 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts @@ -15,3 +15,6 @@ export { getZeroShotClassificationInput } from './zero_shot_classification_input export { FillMaskInference } from './fill_mask_inference'; export { getFillMaskOutputComponent } from './fill_mask_output'; + +export { LangIdentInference } from './lang_ident_inference'; +export { getLangIdentOutputComponent } from './lang_ident_output'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_codes.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_codes.ts similarity index 100% rename from x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_codes.ts rename to x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_codes.ts diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts new file mode 100644 index 0000000000000..a56d4a3598a66 --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InferenceBase, InferenceType } from '../inference_base'; +import { processResponse } from './common'; +import { getGeneralInputComponent } from '../text_input'; +import { getLangIdentOutputComponent } from './lang_ident_output'; +import type { TextClassificationResponse, RawTextClassificationResponse } from './common'; + +export class LangIdentInference extends InferenceBase { + protected inferenceType: InferenceType = 'classification'; + + public async infer() { + try { + this.setRunning(); + const inputText = this.inputText$.getValue(); + const payload = { + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), + }; + const resp = (await this.trainedModelsApi.inferTrainedModel( + this.model.model_id, + payload, + '30s' + )) as unknown as RawTextClassificationResponse; + + const processedResponse: TextClassificationResponse = processResponse( + resp, + this.model, + inputText + ); + this.inferenceResult$.next(processedResponse); + this.setFinished(); + + return processedResponse; + } catch (error) { + this.setFinishedWithErrors(error); + throw error; + } + } + + public getInputComponent(): JSX.Element { + return getGeneralInputComponent(this); + } + + public getOutputComponent(): JSX.Element { + return getLangIdentOutputComponent(this); + } +} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx similarity index 51% rename from x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx rename to x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx index 584e367aac784..a4f2a6e2884e1 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx @@ -8,12 +8,11 @@ import React, { FC } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiBasicTable, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import type { LangIdentInference } from './lang_ident_inference'; import { getLanguage } from './lang_codes'; - -const PROBABILITY_SIG_FIGS = 3; +import { getTextClassificationOutputComponent } from './text_classification_output'; export const getLangIdentOutputComponent = (inferrer: LangIdentInference) => ( @@ -25,48 +24,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => return null; } - const lang = getLanguage(result.response[0].className); - - const items = result.response.map(({ className, classProbability }, i) => { - return { - noa: `${i + 1}`, - className: getLanguage(className), - classProbability: `${Number(classProbability).toPrecision(PROBABILITY_SIG_FIGS)}`, - }; - }); - - const columns = [ - { - field: 'noa', - name: '#', - width: '5%', - truncateText: false, - isExpander: false, - }, - { - field: 'className', - name: i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.output.language_title', - { - defaultMessage: 'Language', - } - ), - width: '30%', - truncateText: false, - isExpander: false, - }, - { - field: 'classProbability', - name: i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.output.probability_title', - { - defaultMessage: 'Probability', - } - ), - truncateText: false, - isExpander: false, - }, - ]; + const lang = getLanguage(result.response[0].value); const title = lang !== 'unknown' @@ -76,7 +34,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => }) : i18n.translate('xpack.ml.trainedModels.testModelsFlyout.langIdent.output.titleUnknown', { defaultMessage: 'Language code unknown: {code}', - values: { code: result.response[0].className }, + values: { code: result.response[0].value }, }); return ( @@ -86,7 +44,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => - + {getTextClassificationOutputComponent(inferrer)} ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts index 1bebdc6ac82e4..33aa1c0f1c86d 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts @@ -10,18 +10,18 @@ import { processResponse } from './common'; import type { TextClassificationResponse, RawTextClassificationResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; import { getTextClassificationOutputComponent } from './text_classification_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export class TextClassificationInference extends InferenceBase { - // @ts-expect-error model type is wrong - private numTopClasses = this.model.inference_config?.text_classification?.num_top_classes || 5; + protected inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION; public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, - inference_config: { text_classification: { num_top_classes: this.numTopClasses } }, + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx index faeed456d1a21..69ecf621510af 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx @@ -9,14 +9,27 @@ import React, { FC } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiProgress } from '@elastic/eui'; -import type { TextClassificationInference, ZeroShotClassificationInference } from '.'; +import type { + TextClassificationInference, + ZeroShotClassificationInference, + FillMaskInference, + LangIdentInference, +} from '.'; export const getTextClassificationOutputComponent = ( - inferrer: TextClassificationInference | ZeroShotClassificationInference + inferrer: + | TextClassificationInference + | ZeroShotClassificationInference + | FillMaskInference + | LangIdentInference ) => ; -const TextClassificationOutput: FC<{ - inferrer: TextClassificationInference | ZeroShotClassificationInference; +export const TextClassificationOutput: FC<{ + inferrer: + | TextClassificationInference + | ZeroShotClassificationInference + | FillMaskInference + | LangIdentInference; }> = ({ inferrer }) => { const result = useObservable(inferrer.inferenceResult$); if (!result) { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts index b8897c439ece8..9a093cc44c170 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts @@ -12,20 +12,23 @@ import type { TextClassificationResponse, RawTextClassificationResponse } from ' import { getZeroShotClassificationInput } from './zero_shot_classification_input'; import { getTextClassificationOutputComponent } from './text_classification_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export class ZeroShotClassificationInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.ZERO_SHOT_CLASSIFICATION; + public labelsText$ = new BehaviorSubject(''); public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const labelsText = this.labelsText$.value; const inputLabels = labelsText?.split(',').map((l) => l.trim()); const payload = { - docs: { [this.inputField]: inputText }, + docs: [{ [this.inputField]: inputText }], inference_config: { - zero_shot_classification: { + [this.inferenceType]: { labels: inputLabels, multi_label: false, }, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts index ffddc81938f19..3613f66d3ed93 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts @@ -10,9 +10,10 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getTextEmbeddingOutputComponent } from './text_embedding_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export interface RawTextEmbeddingResponse { - predicted_value: number[]; + inference_results: [{ predicted_value: number[] }]; } export interface FormattedTextEmbeddingResponse { @@ -25,12 +26,14 @@ export type TextEmbeddingResponse = InferResponse< >; export class TextEmbeddingInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING; + public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, + docs: [{ [this.inputField]: inputText }], }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, @@ -63,6 +66,6 @@ function processResponse( model: estypes.MlTrainedModelConfig, inputText: string ) { - const predictedValue = resp.predicted_value; + const predictedValue = resp.inference_results[0].predicted_value; return { response: { predictedValue }, rawResponse: resp, inputText }; } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx index 50a5f9615d126..816166c5cbcbf 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx @@ -10,12 +10,11 @@ import React, { FC } from 'react'; import { NerInference } from './models/ner'; -import { LangIdentInference } from './models/lang_ident'; - import { TextClassificationInference, FillMaskInference, ZeroShotClassificationInference, + LangIdentInference, } from './models/text_classification'; import { TextEmbeddingInference } from './models/text_embedding'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts index 99a0f76891c30..3ac6ec77f576a 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts @@ -5,26 +5,37 @@ * 2.0. */ -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TRAINED_MODEL_TYPE, + DEPLOYMENT_STATE, SUPPORTED_PYTORCH_TASKS, } from '../../../../../common/constants/trained_models'; import type { SupportedPytorchTasksType } from '../../../../../common/constants/trained_models'; +import type { ModelItem } from '../models_list'; + +import { isPopulatedObject } from '../../../../../common'; const PYTORCH_TYPES = Object.values(SUPPORTED_PYTORCH_TASKS); -export function isTestable(model: estypes.MlTrainedModelConfig) { +export function isTestable(modelItem: ModelItem) { if ( - model.model_type === TRAINED_MODEL_TYPE.PYTORCH && - PYTORCH_TYPES.includes(Object.keys(model.inference_config)[0] as SupportedPytorchTasksType) + modelItem.model_type === TRAINED_MODEL_TYPE.PYTORCH && + PYTORCH_TYPES.includes(Object.keys(modelItem.inference_config)[0] as SupportedPytorchTasksType) ) { return true; } - if (model.model_type === TRAINED_MODEL_TYPE.LANG_IDENT) { + if (modelItem.model_type === TRAINED_MODEL_TYPE.LANG_IDENT) { return true; } return false; } + +export function isTestEnabled(modelItem: ModelItem) { + return ( + isPopulatedObject(modelItem.stats?.deployment_stats) === false || + (isPopulatedObject(modelItem.stats?.deployment_stats) && + modelItem.stats?.deployment_stats?.state === DEPLOYMENT_STATE.STARTED) + ); +} diff --git a/x-pack/plugins/ml/readme.md b/x-pack/plugins/ml/readme.md index 7bd1a1a221edd..9b155e5f7696c 100644 --- a/x-pack/plugins/ml/readme.md +++ b/x-pack/plugins/ml/readme.md @@ -104,42 +104,49 @@ Run the following commands from the `x-pack` directory and use separate terminal for test server and test runner. The test server command starts an Elasticsearch and Kibana instance that the tests will be run against. -1. Functional UI tests with `Trial` license (default config): - - node scripts/functional_tests_server.js - node scripts/functional_test_runner.js --include-tag mlqa - - ML functional `Trial` license tests are located in `x-pack/test/functional/apps/ml`. - +Functional tests are broken up into independent groups with their own configuration. +Test server and runner need to be pointed to the configuration to run. The basic +commands are + + node scripts/functional_tests_server.js --config PATH_TO_CONFIG + node scripts/functional_test_runner.js --config PATH_TO_CONFIG + +With PATH_TO_CONFIG and other options as follows. + +1. Functional UI tests with `Trial` license: + + Group | PATH_TO_CONFIG + ----- | -------------- + anomaly detection | `test/functional/apps/ml/anomaly_detection/config.ts` + data frame analytics | `test/functional/apps/ml/anomaly_detection/config.ts` + data visualizer | `test/functional/apps/ml/data_frame_analytics/config.ts` + permissions | `test/functional/apps/ml/permissions/config.ts` + stack management jobs | `test/functional/apps/ml/stack_management_jobs/config.ts` + short tests | `test/functional/apps/ml/short_tests/config.ts` + + The `short tests` group contains tests for page navigation, model management, + feature controls, settings and embeddables. Test files for each group are located + in the directory of their copnfiguration file. + 1. Functional UI tests with `Basic` license: - node scripts/functional_tests_server.js --config test/functional_basic/config.ts - node scripts/functional_test_runner.js --config test/functional_basic/config.ts --include-tag mlqa - - ML functional `Basic` license tests are located in `x-pack/test/functional_basic/apps/ml`. + - PATH_TO_CONFIG: `test/functional_basic/config.ts` + - Add `--include-tag ml` to the test runner command + - Tests are located in `x-pack/test/functional_basic/apps/ml` 1. API integration tests with `Trial` license: - node scripts/functional_tests_server.js - node scripts/functional_test_runner.js --config test/api_integration/config.ts --include-tag mlqa - - ML API integration `Trial` license tests are located in `x-pack/test/api_integration/apis/ml`. - -1. API integration tests with `Basic` license: - - node scripts/functional_tests_server.js --config test/api_integration_basic/config.ts - node scripts/functional_test_runner.js --config test/api_integration_basic/config.ts --include-tag mlqa - - ML API integration `Basic` license tests are located in `x-pack/test/api_integration_basic/apis/ml`. + - PATH_TO_CONFIG: `test/api_integration/config.ts` + - Add `--include-tag ml` to the test runner command + - Tests are located in `x-pack/test/api_integration/apis/ml` 1. Accessibility tests: We maintain a suite of accessibility tests (you may see them referred to elsewhere as `a11y` tests). These tests render each of our pages and ensure that the inputs and other elements contain the attributes necessary to ensure all users are able to make use of ML (for example, users relying on screen readers). - node scripts/functional_tests_server --config test/accessibility/config.ts - node scripts/functional_test_runner.js --config test/accessibility/config.ts --grep=ml - - ML accessibility tests are located in `x-pack/test/accessibility/apps`. + - PATH_TO_CONFIG: `test/accessibility/config.ts` + - Add `--grep=ml` to the test runner command + - Tests are located in `x-pack/test/accessibility/apps` ## Generating docs screenshots @@ -151,7 +158,7 @@ for test server and test runner. The test server command starts an Elasticsearch and Kibana instance that the tests will be run against. node scripts/functional_tests_server.js --config test/screenshot_creation/config.ts - node scripts/functional_test_runner.js --config test/screenshot_creation/config.ts --include-tag mlqa + node scripts/functional_test_runner.js --config test/screenshot_creation/config.ts --include-tag ml The generated screenshots are stored in `x-pack/test/functional/screenshots/session/ml_docs`. ML screenshot generation tests are located in `x-pack/test/screenshot_creation/apps/ml_docs`. diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index f322f09356c90..808c77d4d6ea5 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -496,7 +496,28 @@ export function getMlClient( }, async inferTrainedModelDeployment(...p: Parameters) { await modelIdsCheck(p); - return mlClient.inferTrainedModelDeployment(...p); + // Temporary workaround for the incorrect inferTrainedModelDeployment function in the esclient + if ( + // @ts-expect-error TS complains it's always false + p.length === 0 || + p[0] === undefined + ) { + // Temporary generic error message. This should never be triggered + // but is added for type correctness below + throw new Error('Incorrect arguments supplied'); + } + // @ts-expect-error body doesn't exist in the type + const { model_id: id, body, query: querystring } = p[0]; + + return client.asInternalUser.transport.request( + { + method: 'POST', + path: `/_ml/trained_models/${id}/_infer`, + body, + querystring, + }, + p[1] + ); }, async info(...p: Parameters) { return mlClient.info(...p); diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index ac09aee7fcbb9..e36e51aded4ba 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -172,7 +172,6 @@ "PutTrainedModel", "DeleteTrainedModel", "InferTrainedModelDeployment", - "IngestPipelineSimulate", "Alerting", "PreviewAlert" diff --git a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts index ea18930cdec36..2273bb48c4f32 100644 --- a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts @@ -31,17 +31,6 @@ export const putTrainedModelQuerySchema = schema.object({ defer_definition_decompression: schema.maybe(schema.boolean()), }); -export const pipelineSchema = schema.object({ - pipeline: schema.object({ - description: schema.maybe(schema.string()), - processors: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), - version: schema.maybe(schema.number()), - on_failure: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), - }), - docs: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), - verbose: schema.maybe(schema.boolean()), -}); - export const inferTrainedModelQuery = schema.object({ timeout: schema.maybe(schema.string()) }); export const inferTrainedModelBody = schema.object({ docs: schema.any(), diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index 731d159032d45..4c8893b3144ea 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { schema } from '@kbn/config-schema'; import { RouteInitialization } from '../types'; import { wrapError } from '../client/error_wrapper'; @@ -14,7 +13,6 @@ import { modelIdSchema, optionalModelIdSchema, putTrainedModelQuerySchema, - pipelineSchema, inferTrainedModelQuery, inferTrainedModelBody, } from './schemas/inference_schema'; @@ -397,41 +395,4 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) } }) ); - - /** - * @apiGroup TrainedModels - * - * @api {post} /api/ml/trained_models/ingest_pipeline_simulate Ingest pipeline simulate - * @apiName IngestPipelineSimulate - * @apiDescription Simulates an ingest pipeline call using supplied documents - */ - router.post( - { - path: '/api/ml/trained_models/ingest_pipeline_simulate', - validate: { - body: pipelineSchema, - }, - options: { - tags: ['access:ml:canStartStopTrainedModels'], - }, - }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response }) => { - try { - const { pipeline, docs, verbose } = request.body; - - const body = await client.asCurrentUser.ingest.simulate({ - verbose, - body: { - pipeline, - docs: docs as estypes.IngestSimulateDocument[], - }, - }); - return response.ok({ - body, - }); - } catch (e) { - return response.customError(wrapError(e)); - } - }) - ); } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/index.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts similarity index 53% rename from x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/index.ts rename to x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts index 8e5bd8cdbeb5f..e68a2920155a5 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/index.ts +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts @@ -5,6 +5,8 @@ * 2.0. */ -export type { FormattedLangIdentResponse, LangIdentResponse } from './lang_ident_inference'; -export { LangIdentInference } from './lang_ident_inference'; -export { getLangIdentOutputComponent } from './lang_ident_output'; +import * as rt from 'io-ts'; + +export const getElasticsearchSettingsClusterResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.ts new file mode 100644 index 0000000000000..2621683b85d97 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.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. + */ + +import * as rt from 'io-ts'; + +export const getElasticsearchSettingsNodesResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/index.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/index.ts new file mode 100644 index 0000000000000..3268982b69b9a --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './get_elasticsearch_settings_cluster'; +export * from './get_elasticsearch_settings_nodes'; +export * from './post_elasticsearch_settings_internal_monitoring'; +export * from './put_elasticsearch_settings_collection_enabled'; +export * from './put_elasticsearch_settings_collection_interval'; diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts new file mode 100644 index 0000000000000..54b65d4c1c527 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { ccsRT } from '../shared'; + +export const postElasticsearchSettingsInternalMonitoringRequestPayloadRT = rt.partial({ + ccs: ccsRT, +}); + +export type PostElasticsearchSettingsInternalMonitoringRequestPayload = rt.TypeOf< + typeof postElasticsearchSettingsInternalMonitoringRequestPayloadRT +>; + +export const postElasticsearchSettingsInternalMonitoringResponsePayloadRT = rt.type({ + body: rt.type({ + legacy_indices: rt.number, + mb_indices: rt.number, + }), +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.ts new file mode 100644 index 0000000000000..f65fdaddc4548 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.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. + */ + +import * as rt from 'io-ts'; + +export const putElasticsearchSettingsCollectionEnabledResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.ts new file mode 100644 index 0000000000000..da4905c044fe0 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.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. + */ + +import * as rt from 'io-ts'; + +export const putElasticsearchSettingsCollectionIntervalResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.ts similarity index 65% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.ts index 6996c4885d25d..df2fafa2a952c 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/cluster.ts @@ -5,25 +5,25 @@ * 2.0. */ +import { getElasticsearchSettingsClusterResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { checkClusterSettings } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function clusterSettingsCheckRoute(server) { +export function clusterSettingsCheckRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/elasticsearch_settings/check/cluster', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await checkClusterSettings(req); // needs to be try/catch to handle privilege error - return response; + return getElasticsearchSettingsClusterResponsePayloadRT.encode(response); } catch (err) { - console.log(err); + server.log.error(err); throw handleSettingsError(err); } }, diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts index 8bee3f273e107..11e0eec3f08f0 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -5,17 +5,21 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; -import { RequestHandlerContext } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { RequestHandlerContext } from '@kbn/core/server'; +import { prefixIndexPatternWithCcs } from '../../../../../../common/ccs_utils'; import { INDEX_PATTERN_ELASTICSEARCH, INDEX_PATTERN_KIBANA, INDEX_PATTERN_LOGSTASH, } from '../../../../../../common/constants'; -import { prefixIndexPatternWithCcs } from '../../../../../../common/ccs_utils'; +import { + postElasticsearchSettingsInternalMonitoringRequestPayloadRT, + postElasticsearchSettingsInternalMonitoringResponsePayloadRT, +} from '../../../../../../common/http_api/elasticsearch_settings'; +import { createValidationFunction } from '../../../../../lib/create_route_validation_function'; import { handleError } from '../../../../../lib/errors'; -import { RouteDependencies, LegacyServer } from '../../../../../types'; +import { LegacyServer, RouteDependencies } from '../../../../../types'; const queryBody = { size: 0, @@ -69,13 +73,15 @@ const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, ind }; export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: RouteDependencies) { + const validateBody = createValidationFunction( + postElasticsearchSettingsInternalMonitoringRequestPayloadRT + ); + npRoute.router.post( { path: '/api/monitoring/v1/elasticsearch_settings/check/internal_monitoring', validate: { - body: schema.object({ - ccs: schema.maybe(schema.string()), - }), + body: validateBody, }, }, async (context, request, response) => { @@ -101,9 +107,11 @@ export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: Rout typeCount.mb_indices += counts.mbIndicesCount; }); - return response.ok({ - body: typeCount, - }); + return response.ok( + postElasticsearchSettingsInternalMonitoringResponsePayloadRT.encode({ + body: typeCount, + }) + ); } catch (err) { throw handleError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts similarity index 67% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts index fe675302a982f..90c37c6f910c9 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { getElasticsearchSettingsNodesResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { checkNodesSettings } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function nodesSettingsCheckRoute(server) { +export function nodesSettingsCheckRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/elasticsearch_settings/check/nodes', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await checkNodesSettings(req); // needs to be try/catch to handle privilege error - return response; + return getElasticsearchSettingsNodesResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts index 8eb50a57fb858..61bb1ba804a5a 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { clusterSettingsCheckRoute } from './check/cluster'; +export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { nodesSettingsCheckRoute } from './check/nodes'; export { setCollectionEnabledRoute } from './set/collection_enabled'; export { setCollectionIntervalRoute } from './set/collection_interval'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts similarity index 64% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts index c8bf24156f129..941818699ede2 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { putElasticsearchSettingsCollectionEnabledResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { setCollectionEnabled } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function setCollectionEnabledRoute(server) { +export function setCollectionEnabledRoute(server: MonitoringCore) { server.route({ - method: 'PUT', + method: 'put', path: '/api/monitoring/v1/elasticsearch_settings/set/collection_enabled', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await setCollectionEnabled(req); - return response; + return putElasticsearchSettingsCollectionEnabledResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts similarity index 64% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts index 60216650062c0..eb4798efc36cc 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { putElasticsearchSettingsCollectionIntervalResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { setCollectionInterval } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function setCollectionIntervalRoute(server) { +export function setCollectionIntervalRoute(server: MonitoringCore) { server.route({ - method: 'PUT', + method: 'put', path: '/api/monitoring/v1/elasticsearch_settings/set/collection_interval', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await setCollectionInterval(req); - return response; + return putElasticsearchSettingsCollectionIntervalResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/saved_query.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/saved_query.ndjson index b29c4e28e731d..0e1a2f4b67cac 100644 --- a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/saved_query.ndjson +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/saved_query.ndjson @@ -14,6 +14,7 @@ "id": "Saved-Query-Id", "interval": "3600", "query": "select * from uptime;", + "platform": "linux,darwin", "updated_at": "2021-12-21T08:54:38.648Z", "updated_by": "elastic" }, diff --git a/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/all/edit_saved_queries.spec.ts similarity index 59% rename from x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts rename to x-pack/plugins/osquery/cypress/integration/all/edit_saved_queries.spec.ts index 1ce25a77f834a..6dde0013a4bc6 100644 --- a/x-pack/plugins/osquery/cypress/integration/all/delete_all_ecs_mappings.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/all/edit_saved_queries.spec.ts @@ -10,7 +10,7 @@ import { login } from '../../tasks/login'; import { ArchiverMethod, runKbnArchiverScript } from '../../tasks/archiver'; import { ROLES } from '../../test'; -describe('ALL - Delete ECS Mappings', () => { +describe('ALL - Edit saved query', () => { const SAVED_QUERY_ID = 'Saved-Query-Id'; before(() => { @@ -25,7 +25,7 @@ describe('ALL - Delete ECS Mappings', () => { runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); }); - it('to click the edit button and edit pack', () => { + it('by changing ecs mappings and platforms', () => { cy.react('CustomItemAction', { props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, }).click(); @@ -35,7 +35,33 @@ describe('ALL - Delete ECS Mappings', () => { .parents('[data-test-subj="ECSMappingEditorForm"]') .react('EuiButtonIcon', { props: { iconType: 'trash' } }) .click(); + + cy.react('PlatformCheckBoxGroupField').within(() => { + cy.react('EuiCheckbox', { + props: { + id: 'linux', + checked: true, + }, + }).should('exist'); + cy.react('EuiCheckbox', { + props: { + id: 'darwin', + checked: true, + }, + }).should('exist'); + + cy.react('EuiCheckbox', { + props: { + id: 'windows', + checked: false, + }, + }).should('exist'); + }); + + cy.get('#windows').check({ force: true }); + cy.react('EuiButton').contains('Update query').click(); + cy.wait(5000); cy.react('CustomItemAction', { @@ -43,5 +69,27 @@ describe('ALL - Delete ECS Mappings', () => { }).click(); cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Hours of uptime').should('not.exist'); + + cy.react('PlatformCheckBoxGroupField').within(() => { + cy.react('EuiCheckbox', { + props: { + id: 'linux', + checked: true, + }, + }).should('exist'); + cy.react('EuiCheckbox', { + props: { + id: 'darwin', + checked: true, + }, + }).should('exist'); + + cy.react('EuiCheckbox', { + props: { + id: 'windows', + checked: true, + }, + }).should('exist'); + }); }); }); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx index 6da252f78aedf..1d0d9f28d097b 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx @@ -56,13 +56,6 @@ export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryF defaultValue, serializer: (payload) => produce(payload, (draft) => { - // @ts-expect-error update types - if (draft.platform?.split(',').length === 3) { - // if all platforms are checked then use undefined - // @ts-expect-error update types - delete draft.platform; - } - if (isArray(draft.version)) { if (!draft.version.length) { // @ts-expect-error update types diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 4187db5b20641..935f3e297b2cb 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -188,42 +188,40 @@ Array [ `; exports[`stream handler showNotifications show success 1`] = ` -Array [ - Object { - "color": "success", - "data-test-subj": "completeReportSuccess", - "text": MountPoint { - "reactNode": -

- -

- +

+ - , - }, - "title": MountPoint { - "reactNode": + , - }, + /> + , }, -] + "title": MountPoint { + "reactNode": , + }, +} `; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 6f575652450c1..d3075d4e5a906 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import sinon, { stub } from 'sinon'; import { NotificationsStart } from '@kbn/core/public'; import { coreMock, themeServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks'; @@ -123,7 +124,7 @@ describe('stream handler', () => { expect(mockShowDanger.callCount).toBe(0); expect(mockShowSuccess.callCount).toBe(1); expect(mockShowWarning.callCount).toBe(0); - expect(mockShowSuccess.args[0]).toMatchSnapshot(); + expect(omit(mockShowSuccess.args[0][0], 'toastLifeTimeMs')).toMatchSnapshot(); done(); }); }); diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index 44389e164472a..f7b71d78de8bd 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -37,5 +37,12 @@ export const getSuccessToast = ( , { theme$: theme.theme$ } ), + /** + * If timeout is an Infinity value, a Not-a-Number (NaN) value, or negative, then timeout will be zero. + * And we cannot use `Number.MAX_SAFE_INTEGER` because EUI's Timer implementation + * subtracts it from the current time to evaluate the remainder. + * @see https://www.w3.org/TR/2011/WD-html5-20110525/timers.html + */ + toastLifeTimeMs: Number.MAX_SAFE_INTEGER - Date.now(), 'data-test-subj': 'completeReportSuccess', }); diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/LICENSE_OFL.txt b/x-pack/plugins/screenshotting/server/assets/fonts/noto/LICENSE_OFL.txt similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/LICENSE_OFL.txt rename to x-pack/plugins/screenshotting/server/assets/fonts/noto/LICENSE_OFL.txt diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Medium.ttf b/x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Medium.ttf similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Medium.ttf rename to x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Medium.ttf diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Regular.ttf b/x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Regular.ttf similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Regular.ttf rename to x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Regular.ttf diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/index.js b/x-pack/plugins/screenshotting/server/assets/fonts/noto/index.js similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/index.js rename to x-pack/plugins/screenshotting/server/assets/fonts/noto/index.js diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/LICENSE.txt b/x-pack/plugins/screenshotting/server/assets/fonts/roboto/LICENSE.txt similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/LICENSE.txt rename to x-pack/plugins/screenshotting/server/assets/fonts/roboto/LICENSE.txt diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Italic.ttf b/x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Italic.ttf similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Italic.ttf rename to x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Italic.ttf diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Medium.ttf b/x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Medium.ttf similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Medium.ttf rename to x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Medium.ttf diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Regular.ttf b/x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Regular.ttf similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Regular.ttf rename to x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Regular.ttf diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/img/logo-grey.png b/x-pack/plugins/screenshotting/server/assets/img/logo-grey.png similarity index 100% rename from x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/img/logo-grey.png rename to x-pack/plugins/screenshotting/server/assets/img/logo-grey.png diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts index fd09396d6c86d..66f905bd07cb2 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts @@ -17,6 +17,7 @@ import { import { ConfigType } from '../../config'; import { allowRequest } from '../network_policy'; import { stripUnsafeHeaders } from './strip_unsafe_headers'; +import { getFooterTemplate, getHeaderTemplate } from './templates'; export type Context = Record; @@ -155,6 +156,18 @@ export class HeadlessChromiumDriver { return !this.page.isClosed(); } + async printA4Pdf({ title, logo }: { title: string; logo?: string }): Promise { + return this.page.pdf({ + format: 'a4', + preferCSSPageSize: true, + scale: 1, + landscape: false, + displayHeaderFooter: true, + headerTemplate: await getHeaderTemplate({ title }), + footerTemplate: await getFooterTemplate({ logo }), + }); + } + /* * Call Page.screenshot and return a base64-encoded string of the image */ @@ -359,7 +372,7 @@ export class HeadlessChromiumDriver { // `port` is null in URLs that don't explicitly state it, // however we can derive the port from the protocol (http/https) - // IE: https://feeds-staging.elastic.co/kibana/v8.0.0.json + // IE: https://feeds.elastic.co/kibana/v8.0.0.json const derivedPort = (protocol: string | null, port: string | null, url: string) => { if (port) { return port; diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index 7d31cdc0c6b8c..bfdc74aa43ba6 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -114,10 +114,6 @@ export class HeadlessChromiumDriverFactory { const dataDir = getDataPath(); fs.mkdirSync(dataDir, { recursive: true }); this.userDataDir = fs.mkdtempSync(path.join(dataDir, 'chromium-')); - - if (this.config.browser.chromium.disableSandbox) { - logger.warn(`Enabling the Chromium sandbox provides an additional layer of protection.`); - } } private getChromiumArgs() { diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts index d74313fa5ace1..1a04574155c1e 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts @@ -67,8 +67,8 @@ export class ChromiumArchivePaths { { platform: 'linux', architecture: 'x64', - archiveFilename: 'chromium-70f5d88-linux_x64.zip', - archiveChecksum: '7b1c9c2fb613444fbdf004a3b75a58df', + archiveFilename: 'chromium-70f5d88-locales-linux_x64.zip', + archiveChecksum: '759bda5e5d32533cb136a85e37c0d102', binaryChecksum: '82e80f9727a88ba3836ce230134bd126', binaryRelativePath: 'headless_shell-linux_x64/headless_shell', location: 'custom', @@ -78,8 +78,8 @@ export class ChromiumArchivePaths { { platform: 'linux', architecture: 'arm64', - archiveFilename: 'chromium-70f5d88-linux_arm64.zip', - archiveChecksum: '4a0217cfe7da86ad1e3d0e9e5895ddb5', + archiveFilename: 'chromium-70f5d88-locales-linux_arm64.zip', + archiveChecksum: '33613b8dc5212c0457210d5a37ea4b43', binaryChecksum: '29e943fbee6d87a217abd6cb6747058e', binaryRelativePath: 'headless_shell-linux_arm64/headless_shell', location: 'custom', diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/templates/footer.handlebars.html b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/footer.handlebars.html new file mode 100644 index 0000000000000..ddd85a50fc6a5 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/footer.handlebars.html @@ -0,0 +1,47 @@ + +

+ + + {{#if hasCustomLogo}} +
{{poweredByElasticCopy}}
+ {{/if}} +
+  of  +
+
diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/templates/header.handlebars.html b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/header.handlebars.html new file mode 100644 index 0000000000000..616e5f753b233 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/header.handlebars.html @@ -0,0 +1,10 @@ + +{{title}} diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/templates/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/index.ts new file mode 100644 index 0000000000000..7034dac76cfca --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/templates/index.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import fs from 'fs/promises'; +import path from 'path'; +import Handlebars from 'handlebars'; +import { assetPath } from '../../../constants'; + +async function compileTemplate(pathToTemplate: string): Promise> { + const contentsBuffer = await fs.readFile(pathToTemplate); + return Handlebars.compile(contentsBuffer.toString()); +} + +interface HeaderTemplateInput { + title: string; +} +interface GetHeaderArgs { + title: string; +} + +export async function getHeaderTemplate({ title }: GetHeaderArgs): Promise { + const template = await compileTemplate( + path.resolve(__dirname, './header.handlebars.html') + ); + return template({ title }); +} + +async function getDefaultFooterLogo(): Promise { + const logoBuffer = await fs.readFile(path.resolve(assetPath, 'img', 'logo-grey.png')); + return `data:image/png;base64,${logoBuffer.toString('base64')}`; +} + +interface FooterTemplateInput { + base64FooterLogo: string; + hasCustomLogo: boolean; + poweredByElasticCopy: string; +} + +interface GetFooterArgs { + logo?: string; +} + +export async function getFooterTemplate({ logo }: GetFooterArgs): Promise { + const template = await compileTemplate( + path.resolve(__dirname, './footer.handlebars.html') + ); + const hasCustomLogo = Boolean(logo); + return template({ + base64FooterLogo: hasCustomLogo ? logo! : await getDefaultFooterLogo(), + hasCustomLogo, + poweredByElasticCopy: i18n.translate( + 'xpack.screenshotting.exportTypes.printablePdf.footer.logoDescription', + { + defaultMessage: 'Powered by Elastic', + } + ), + }); +} diff --git a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts index 6805996fb1a5a..74a80cf10b58b 100644 --- a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts @@ -88,11 +88,11 @@ describe('ensureDownloaded', () => { expect.arrayContaining([ 'chrome-mac.zip', 'chrome-win.zip', - 'chromium-70f5d88-linux_x64.zip', + 'chromium-70f5d88-locales-linux_x64.zip', ]) ); expect(readdirSync(path.resolve(`${paths.archivesPath}/arm64`))).toEqual( - expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-linux_arm64.zip']) + expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-locales-linux_arm64.zip']) ); }); diff --git a/x-pack/plugins/screenshotting/server/config/create_config.ts b/x-pack/plugins/screenshotting/server/config/create_config.ts index f12f2205d3a57..1b7076d05e478 100644 --- a/x-pack/plugins/screenshotting/server/config/create_config.ts +++ b/x-pack/plugins/screenshotting/server/config/create_config.ts @@ -24,13 +24,13 @@ export async function createConfig(parentLogger: Logger, config: ConfigType) { // disableSandbox was not set by user, apply default for OS const { os, disableSandbox } = await getDefaultChromiumSandboxDisabled(); - const osName = [os.os, os.dist, os.release].filter(Boolean).map(upperFirst).join(' '); + const osName = [os.os, os.dist, os.release].filter(Boolean).map(upperFirst).join(' ').trim(); logger.debug(`Running on OS: '${osName}'`); if (disableSandbox === true) { logger.warn( - `Chromium sandbox provides an additional layer of protection, but is not supported for ${osName} OS. Automatically setting 'xpack.screenshotting.capture.browser.chromium.disableSandbox: true'.` + `Chromium sandbox provides an additional layer of protection, but is not supported for ${osName} OS. Automatically setting 'xpack.screenshotting.browser.chromium.disableSandbox: true'.` ); } else { logger.info( diff --git a/x-pack/test/fleet_api_integration/apis/mock_http_server.d.ts b/x-pack/plugins/screenshotting/server/constants.ts similarity index 71% rename from x-pack/test/fleet_api_integration/apis/mock_http_server.d.ts rename to x-pack/plugins/screenshotting/server/constants.ts index ac8e88b6fefe3..38fde163778ec 100644 --- a/x-pack/test/fleet_api_integration/apis/mock_http_server.d.ts +++ b/x-pack/plugins/screenshotting/server/constants.ts @@ -5,6 +5,6 @@ * 2.0. */ -// No types for mock-http-server available, but we don't need them. +import path from 'path'; -declare module 'mock-http-server'; +export const assetPath = path.resolve(__dirname, 'assets'); diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/index.ts b/x-pack/plugins/screenshotting/server/formats/pdf/index.ts index ce28c53bb5f88..716b2bd46352f 100644 --- a/x-pack/plugins/screenshotting/server/formats/pdf/index.ts +++ b/x-pack/plugins/screenshotting/server/formats/pdf/index.ts @@ -5,6 +5,10 @@ * 2.0. */ +// FIXME: Once/if we have the ability to get page count directly from Chrome/puppeteer +// we should get rid of this lib. +import * as PDFJS from 'pdfjs-dist/legacy/build/pdf.js'; + import type { Values } from '@kbn/utility-types'; import { groupBy } from 'lodash'; import type { PackageInfo } from '@kbn/core/server'; @@ -99,30 +103,51 @@ export async function toPdf( { logo, title }: PdfScreenshotOptions, { metrics, results }: CaptureResult ): Promise { - const timeRange = getTimeRange(results); - try { - const { buffer, pages } = await pngsToPdf({ - title: title ? `${title}${timeRange ? ` - ${timeRange}` : ''}` : undefined, - results, - layout, - logo, - packageInfo, - eventLogger, + let buffer: Buffer; + let pages: number; + const shouldConvertPngsToPdf = layout.id !== LayoutTypes.PRINT; + if (shouldConvertPngsToPdf) { + const timeRange = getTimeRange(results); + try { + ({ buffer, pages } = await pngsToPdf({ + title: title ? `${title}${timeRange ? ` - ${timeRange}` : ''}` : undefined, + results, + layout, + logo, + packageInfo, + eventLogger, + })); + + return { + metrics: { + ...(metrics ?? {}), + pages, + }, + data: buffer, + errors: results.flatMap(({ error }) => (error ? [error] : [])), + renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []), + }; + } catch (error) { + eventLogger.kbnLogger.error(`Could not generate the PDF buffer!`); + eventLogger.error(error, Transactions.PDF); + throw error; + } + } else { + buffer = results[0].screenshots[0].data; // This buffer is already the PDF + pages = await PDFJS.getDocument({ data: buffer }).promise.then((doc) => { + const numPages = doc.numPages; + doc.destroy(); + return numPages; }); - - return { - metrics: { - ...(metrics ?? {}), - pages, - }, - data: buffer, - errors: results.flatMap(({ error }) => (error ? [error] : [])), - renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []), - }; - } catch (error) { - eventLogger.kbnLogger.error(`Could not generate the PDF buffer!`); - eventLogger.error(error, Transactions.PDF); - - throw error; } + + return { + metrics: { + ...(metrics ?? {}), + pages, + }, + data: buffer, + errors: results.flatMap(({ error }) => (error ? [error] : [])), + renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []), + }; } diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/constants.ts b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/constants.ts index 3e44a53a7f3c0..03192aacd887f 100644 --- a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/constants.ts +++ b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/constants.ts @@ -5,9 +5,8 @@ * 2.0. */ -import path from 'path'; +import { assetPath } from '../../../constants'; -export const assetPath = path.resolve(__dirname, 'assets'); export const tableBorderWidth = 1; export const pageMarginTop = 40; export const pageMarginBottom = 80; @@ -21,3 +20,4 @@ export const subheadingMarginTop = 0; export const subheadingMarginBottom = 5; export const subheadingHeight = subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom; +export { assetPath }; diff --git a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/pdfmaker.ts b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/pdfmaker.ts index afd9e294e9ae0..7dd964594ca53 100644 --- a/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/pdfmaker.ts +++ b/x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/pdfmaker.ts @@ -83,6 +83,8 @@ export class PdfMaker { const groupCount = this.content.length; // inject a page break for every 2 groups on the page + // TODO: Remove this code since we are now using Chromium to drive this + // layout via native print functionality. if (groupCount > 0 && groupCount % this.layout.groupCount === 0) { contents = [ { diff --git a/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts index 033fb24c80685..64027ffbd3cf2 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/event_logger/index.ts @@ -12,7 +12,7 @@ import { CaptureResult } from '..'; import { PLUGIN_ID } from '../../../common'; import { ConfigType } from '../../config'; import { ElementPosition } from '../get_element_position_data'; -import { Screenshot } from '../get_screenshots'; +import type { Screenshot } from '../types'; export enum Actions { OPEN_URL = 'open-url', @@ -25,6 +25,7 @@ export enum Actions { WAIT_RENDER = 'wait-for-render', WAIT_VISUALIZATIONS = 'wait-for-visualizations', GET_SCREENSHOT = 'get-screenshots', + PRINT_A4_PDF = 'print-a4-pdf', ADD_IMAGE = 'add-pdf-image', COMPILE = 'compile-pdf', } diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_pdf.ts b/x-pack/plugins/screenshotting/server/screenshots/get_pdf.ts new file mode 100644 index 0000000000000..026d62ada876c --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/get_pdf.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Actions, EventLogger } from './event_logger'; +import type { HeadlessChromiumDriver } from '../browsers'; +import type { Screenshot } from './types'; + +export async function getPdf( + browser: HeadlessChromiumDriver, + logger: EventLogger, + title: string, + logo?: string +): Promise { + logger.kbnLogger.info('printing PDF'); + + const spanEnd = logger.logPdfEvent('printing A4 PDF', Actions.PRINT_A4_PDF, 'output'); + + const result = [ + { + data: await browser.printA4Pdf({ title, logo }), + title: null, + description: null, + }, + ]; + + spanEnd(); + + return result; +} diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts index f157649bbb848..67cfbd111e750 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts @@ -8,23 +8,7 @@ import type { HeadlessChromiumDriver } from '../browsers'; import { Actions, EventLogger } from './event_logger'; import type { ElementsPositionAndAttribute } from './get_element_position_data'; - -export interface Screenshot { - /** - * Screenshot PNG image data. - */ - data: Buffer; - - /** - * Screenshot title. - */ - title: string | null; - - /** - * Screenshot description. - */ - description: string | null; -} +import type { Screenshot } from './types'; export const getScreenshots = async ( browser: HeadlessChromiumDriver, diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.ts index 5048d3f0a3be6..d06014c82ecc7 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/observable.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.ts @@ -8,7 +8,7 @@ import type { Headers } from '@kbn/core/server'; import { defer, forkJoin, Observable, throwError } from 'rxjs'; import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators'; -import { errors } from '../../common'; +import { errors, LayoutTypes } from '../../common'; import type { Context, HeadlessChromiumDriver } from '../browsers'; import { DEFAULT_VIEWPORT, getChromiumDisconnectedError } from '../browsers'; import { ConfigType, durationToNumber as toNumber } from '../config'; @@ -18,13 +18,15 @@ import type { ElementsPositionAndAttribute } from './get_element_position_data'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getRenderErrors } from './get_render_errors'; -import type { Screenshot } from './get_screenshots'; +import type { Screenshot } from './types'; import { getScreenshots } from './get_screenshots'; +import { getPdf } from './get_pdf'; import { getTimeRange } from './get_time_range'; import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; +import type { PdfScreenshotOptions } from '../formats'; type CaptureTimeouts = ConfigType['capture']['timeouts']; export interface PhaseTimeouts extends CaptureTimeouts { @@ -237,6 +239,26 @@ export class ScreenshotObservableHandler { ); } + /** + * Given a title and time range value look like: + * + * "[Logs] Web Traffic - Apr 14, 2022 @ 120742.318 to Apr 21, 2022 @ 120742.318" + * + * Otherwise closest thing to that or a blank string. + */ + private getTitle(timeRange: null | string): string { + return `${(this.options as PdfScreenshotOptions).title ?? ''} ${ + timeRange ? `- ${timeRange}` : '' + }`.trim(); + } + + private shouldCapturePdf(): boolean { + return ( + this.layout.id === LayoutTypes.PRINT && + (this.options as PdfScreenshotOptions).format === 'pdf' + ); + } + public getScreenshots() { return (withRenderComplete: Observable) => withRenderComplete.pipe( @@ -247,7 +269,14 @@ export class ScreenshotObservableHandler { getDefaultElementPosition(this.layout.getViewport(1)); let screenshots: Screenshot[] = []; try { - screenshots = await getScreenshots(this.driver, this.eventLogger, elements); + screenshots = this.shouldCapturePdf() + ? await getPdf( + this.driver, + this.eventLogger, + this.getTitle(data.timeRange), + (this.options as PdfScreenshotOptions).logo + ) + : await getScreenshots(this.driver, this.eventLogger, elements); } catch (e) { throw new errors.FailedToCaptureScreenshot(e.message); } diff --git a/x-pack/plugins/screenshotting/server/screenshots/types.ts b/x-pack/plugins/screenshotting/server/screenshots/types.ts new file mode 100644 index 0000000000000..d4a408313fc43 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/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. + */ + +export interface Screenshot { + /** + * Screenshot PNG image data. + */ + data: Buffer; + + /** + * Screenshot title. + */ + title: string | null; + + /** + * Screenshot description. + */ + description: string | null; +} diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 1d898901455e7..244143c8eeef6 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -121,7 +121,6 @@ export enum SecurityPageName { usersExternalAlerts = 'users-external_alerts', threatHuntingLanding = 'threat-hunting', dashboardsLanding = 'dashboards', - manageLanding = 'manage', } export const THREAT_HUNTING_PATH = '/threat_hunting' as const; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cf579f7ea5a90..2052a7e0b6cd9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -54,9 +54,10 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; -/** Endpoint Actions Log Routes */ +/** Endpoint Actions Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; +export const ACTION_DETAILS_ROUTE = `/api/endpoint/action/{action_id}`; export const failedFleetActionErrorCode = '424'; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts index 6ddb2fc19ef07..00c157f9a2fd1 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -7,6 +7,7 @@ import seedrandom from 'seedrandom'; import uuid from 'uuid'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; const OS_FAMILY = ['windows', 'macos', 'linux']; /** Array of 14 day offsets */ @@ -180,4 +181,45 @@ export class BaseDataGenerator { protected randomHostname(): string { return `Host-${this.randomString(10)}`; } + + /** + * Returns an single search hit (normally found in a `SearchResponse`) for the given document source. + * @param hitSource + */ + toEsSearchHit(hitSource: T): estypes.SearchHit { + return { + _index: 'some-index', + _id: this.seededUUIDv4(), + _score: 1.0, + _source: hitSource, + }; + } + + /** + * Returns an ES Search Response for the give set of records. Each record will be wrapped with + * the `toEsSearchHit()` + * @param hitsSource + */ + toEsSearchResponse( + hitsSource: Array> + ): estypes.SearchResponse { + return { + took: 3, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: hitsSource.length, + relation: 'eq', + }, + max_score: 0, + hits: hitsSource, + }, + }; + } } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index dd4eeeab15cce..e0ae0f17adbde 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -7,23 +7,34 @@ import { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_INDEX } from '../constants'; import { BaseDataGenerator } from './base_data_generator'; -import { ISOLATION_ACTIONS, LogsEndpointAction, LogsEndpointActionResponse } from '../types'; +import { + ActivityLogItemTypes, + EndpointActivityLogActionResponse, + ISOLATION_ACTIONS, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../types'; const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate']; export class EndpointActionGenerator extends BaseDataGenerator { /** Generate a random endpoint Action request (isolate or unisolate) */ generate(overrides: DeepPartial = {}): LogsEndpointAction { - const timeStamp = new Date(this.randomPastDate()); + const timeStamp = overrides['@timestamp'] + ? new Date(overrides['@timestamp']) + : new Date(this.randomPastDate()); + return merge( { '@timestamp': timeStamp.toISOString(), agent: { - id: [this.randomUUID()], + id: [this.seededUUIDv4()], }, EndpointActions: { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), expiration: this.randomFutureDate(timeStamp), type: 'INPUT_ACTION', input_type: 'endpoint', @@ -41,6 +52,14 @@ export class EndpointActionGenerator extends BaseDataGenerator { ); } + generateActionEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generate(overrides)), { + _index: `.ds-${ENDPOINT_ACTIONS_INDEX}-some_namespace`, + }); + } + generateIsolateAction(overrides: DeepPartial = {}): LogsEndpointAction { return merge(this.generate({ EndpointActions: { data: { command: 'isolate' } } }), overrides); } @@ -53,16 +72,16 @@ export class EndpointActionGenerator extends BaseDataGenerator { generateResponse( overrides: DeepPartial = {} ): LogsEndpointActionResponse { - const timeStamp = new Date(); + const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date(); return merge( { '@timestamp': timeStamp.toISOString(), agent: { - id: this.randomUUID(), + id: this.seededUUIDv4(), }, EndpointActions: { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), completed_at: timeStamp.toISOString(), data: { command: this.randomIsolateCommand(), @@ -76,6 +95,29 @@ export class EndpointActionGenerator extends BaseDataGenerator { ); } + generateResponseEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generateResponse(overrides)), { + _index: `.ds-${ENDPOINT_ACTION_RESPONSES_DS}-some_namespace-something`, + }); + } + + generateActivityLogActionResponse( + overrides: DeepPartial + ): EndpointActivityLogActionResponse { + return merge( + { + type: ActivityLogItemTypes.RESPONSE, + item: { + id: this.seededUUIDv4(), + data: this.generateResponse(), + }, + }, + overrides + ); + } + randomFloat(): number { return this.random(); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts index abe29f62dfd5b..aca71a2df6d51 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts @@ -7,24 +7,34 @@ import { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; import { BaseDataGenerator } from './base_data_generator'; -import { EndpointAction, EndpointActionResponse, ISOLATION_ACTIONS } from '../types'; +import { + ActivityLogActionResponse, + ActivityLogItemTypes, + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../types'; const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate']; export class FleetActionGenerator extends BaseDataGenerator { /** Generate a random endpoint Action (isolate or unisolate) */ generate(overrides: DeepPartial = {}): EndpointAction { - const timeStamp = new Date(this.randomPastDate()); + const timeStamp = overrides['@timestamp'] + ? new Date(overrides['@timestamp']) + : new Date(this.randomPastDate()); return merge( { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), '@timestamp': timeStamp.toISOString(), expiration: this.randomFutureDate(timeStamp), type: 'INPUT_ACTION', input_type: 'endpoint', - agents: [this.randomUUID()], + agents: [this.seededUUIDv4()], user_id: 'elastic', data: { command: this.randomIsolateCommand(), @@ -35,6 +45,14 @@ export class FleetActionGenerator extends BaseDataGenerator { ); } + generateActionEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generate(overrides)), { + _index: AGENT_ACTIONS_INDEX, + }); + } + generateIsolateAction(overrides: DeepPartial = {}): EndpointAction { return merge(this.generate({ data: { command: 'isolate' } }), overrides); } @@ -45,7 +63,7 @@ export class FleetActionGenerator extends BaseDataGenerator { /** Generates an endpoint action response */ generateResponse(overrides: DeepPartial = {}): EndpointActionResponse { - const timeStamp = new Date(); + const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date(); return merge( { @@ -53,8 +71,8 @@ export class FleetActionGenerator extends BaseDataGenerator { command: this.randomIsolateCommand(), comment: '', }, - action_id: this.randomUUID(), - agent_id: this.randomUUID(), + action_id: this.seededUUIDv4(), + agent_id: this.seededUUIDv4(), started_at: this.randomPastDate(), completed_at: timeStamp.toISOString(), error: 'some error happened', @@ -64,6 +82,33 @@ export class FleetActionGenerator extends BaseDataGenerator { ); } + generateResponseEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generateResponse(overrides)), { + _index: AGENT_ACTIONS_RESULTS_INDEX, + }); + } + + /** + * An Activity Log entry as returned by the Activity log API + * @param overrides + */ + generateActivityLogActionResponse( + overrides: DeepPartial = {} + ): ActivityLogActionResponse { + return merge( + { + type: ActivityLogItemTypes.FLEET_RESPONSE, + item: { + id: this.seededUUIDv4(), + data: this.generateResponse(), + }, + }, + overrides + ); + } + randomFloat(): number { return this.random(); } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 69fce914cb1d5..a13eb48865edd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -42,3 +42,9 @@ export const ActionStatusRequestSchema = { ]), }), }; + +export const ActionDetailsRequestSchema = { + params: schema.object({ + action_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 2ac4c9e772ded..9ca07a26e03ed 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -40,6 +40,11 @@ interface ActionResponseFields { completed_at: string; started_at: string; } + +/** + * An endpoint Action created in the Endpoint's `.logs-endpoint.actions-default` index. + * @since v7.16 + */ export interface LogsEndpointAction { '@timestamp': string; agent: { @@ -52,6 +57,10 @@ export interface LogsEndpointAction { }; } +/** + * An Action response written by the endpoint to the Endpoint `.logs-endpoint.action.responses` datastream + * @since v7.16 + */ export interface LogsEndpointActionResponse { '@timestamp': string; agent: { @@ -72,6 +81,9 @@ export interface FleetActionResponseData { }; } +/** + * And endpoint action created in Fleet's `.fleet-actions` + */ export interface EndpointAction { action_id: string; '@timestamp': string; @@ -136,11 +148,17 @@ export interface ActivityLogActionResponse { data: EndpointActionResponse; }; } + +/** + * One of the possible Response Action Log entry - Either a Fleet Action request, Fleet action response, + * Endpoint action request and/or endpoint action response. + */ export type ActivityLogEntry = | ActivityLogAction | ActivityLogActionResponse | EndpointActivityLogAction | EndpointActivityLogActionResponse; + export interface ActivityLog { page: number; pageSize: number; @@ -168,3 +186,32 @@ export interface PendingActionsResponse { } export type PendingActionsRequestQuery = TypeOf; + +export interface ActionDetails { + /** The action id */ + id: string; + /** + * The Endpoint ID (and fleet agent ID - they are the same) for which the action was created for. + * This is an Array because the action could have been sent to multiple endpoints. + */ + agents: string[]; + /** + * The Endpoint type of action (ex. `isolate`, `release`) that is being requested to be + * performed on the endpoint + */ + command: string; + isExpired: boolean; + isCompleted: boolean; + /** The date when the initial action request was submitted */ + startedAt: string; + /** The date when the action was completed (a response by the endpoint (not fleet) was received) */ + completedAt: string | undefined; + /** + * The list of action log items (actions and responses) received thus far for the action. + */ + logEntries: ActivityLogEntry[]; +} + +export interface ActionDetailsApiResponse { + data: ActionDetails; +} diff --git a/x-pack/plugins/security_solution/cypress/integration/users/users_tabs.spec.ts b/x-pack/plugins/security_solution/cypress/integration/users/users_tabs.spec.ts index 6ae79f41ffc60..a85222a7be77f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/users/users_tabs.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/users/users_tabs.spec.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { HEADER_SUBTITLE } from '../../screens/users/all_users'; +import { HEADER_SUBTITLE, ALL_USERS_TABLE } from '../../screens/users/all_users'; import { ANOMALIES_TAB, ANOMALIES_TAB_CONTENT } from '../../screens/users/user_anomalies'; import { AUTHENTICATIONS_TAB, @@ -39,7 +39,9 @@ describe('Users stats and tables', () => { it(`renders all users`, () => { const totalUsers = 1; - cy.get(HEADER_SUBTITLE).should('have.text', `Showing: ${totalUsers} user`); + cy.get(ALL_USERS_TABLE) + .find(HEADER_SUBTITLE) + .should('have.text', `Showing: ${totalUsers} user`); }); it(`renders all authentications`, () => { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts new file mode 100644 index 0000000000000..3765dfadc8fcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; +import { CASES_PATH, SecurityPageName } from '../../common/constants'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const getCasesLinkItems = (): LinkItem => { + const casesLinks = getCasesDeepLinks({ + basePath: CASES_PATH, + extend: { + [SecurityPageName.case]: { + globalNavEnabled: true, + globalNavOrder: 9006, + features: [FEATURE.casesRead], + }, + [SecurityPageName.caseConfigure]: { + features: [FEATURE.casesCrud], + licenseType: 'gold', + }, + [SecurityPageName.caseCreate]: { + features: [FEATURE.casesCrud], + }, + }, + }); + const { id, deepLinks, ...rest } = casesLinks; + return { + ...rest, + id: SecurityPageName.case, + links: deepLinks as LinkItem[], + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/landing_cards/translations.tsx b/x-pack/plugins/security_solution/public/common/components/landing_cards/translations.tsx index 51da2e72c3bbd..fb91c358486d8 100644 --- a/x-pack/plugins/security_solution/public/common/components/landing_cards/translations.tsx +++ b/x-pack/plugins/security_solution/public/common/components/landing_cards/translations.tsx @@ -42,7 +42,7 @@ export const ENDPOINT_TITLE = i18n.translate( export const ENDPOINT_DESCRIPTION = i18n.translate( 'xpack.securitySolution.overview.landingCards.box.endpoint.desc', { - defaultMessage: 'Prevent, collect, detect and respond -- all with Elastic Agent.', + defaultMessage: 'Prevent, collect, detect and respond — all with Elastic Agent.', } ); @@ -55,7 +55,7 @@ export const SIEM_CARD_TITLE = i18n.translate( export const SIEM_CARD_DESCRIPTION = i18n.translate( 'xpack.securitySolution.overview.landingCards.box.siemCard.desc', { - defaultMessage: 'Detect, investigate, and respond to evolving threats', + defaultMessage: 'Detect, investigate, and respond to evolving threats.', } ); @@ -69,6 +69,6 @@ export const UNIFY_DESCRIPTION = i18n.translate( 'xpack.securitySolution.overview.landingCards.box.unify.desc', { defaultMessage: - 'Elastic Security modernizes security operations — enabling analytics across years of data, automating key processes, and bringing native endpoint security to every host.', + 'Elastic Security modernizes security operations — enabling analytics across years of data, automating key processes, and protecting every host.', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 1cb8a918ea481..bc20a98eae1e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -6,7 +6,7 @@ */ import { UrlStateType } from '../url_state/constants'; -import type { SecurityPageName } from '../../../app/types'; +import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; @@ -40,26 +40,27 @@ export interface NavTab { pageId?: SecurityPageName; isBeta?: boolean; } - -export type SecurityNavKey = - | SecurityPageName.administration - | SecurityPageName.alerts - | SecurityPageName.blocklist - | SecurityPageName.detectionAndResponse - | SecurityPageName.case - | SecurityPageName.endpoints - | SecurityPageName.landing - | SecurityPageName.policies - | SecurityPageName.eventFilters - | SecurityPageName.exceptions - | SecurityPageName.hostIsolationExceptions - | SecurityPageName.hosts - | SecurityPageName.network - | SecurityPageName.overview - | SecurityPageName.rules - | SecurityPageName.timelines - | SecurityPageName.trustedApps - | SecurityPageName.users; +export const securityNavKeys = [ + SecurityPageName.administration, + SecurityPageName.alerts, + SecurityPageName.blocklist, + SecurityPageName.detectionAndResponse, + SecurityPageName.case, + SecurityPageName.endpoints, + SecurityPageName.landing, + SecurityPageName.policies, + SecurityPageName.eventFilters, + SecurityPageName.exceptions, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.hosts, + SecurityPageName.network, + SecurityPageName.overview, + SecurityPageName.rules, + SecurityPageName.timelines, + SecurityPageName.trustedApps, + SecurityPageName.users, +] as const; +export type SecurityNavKey = typeof securityNavKeys[number]; export type SecurityNav = Record; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts b/x-pack/plugins/security_solution/public/common/hooks/use_dashboard_button_href.ts similarity index 74% rename from x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts rename to x-pack/plugins/security_solution/public/common/hooks/use_dashboard_button_href.ts index 5bc2087dc63ab..39e10a88087c7 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_dashboard_button_href.ts @@ -6,16 +6,23 @@ */ import { useState, useEffect } from 'react'; import { SavedObjectAttributes } from '@kbn/securitysolution-io-ts-alerting-types'; -import { useKibana } from '../../../common/lib/kibana'; +import { useKibana } from '../lib/kibana'; -const DASHBOARD_REQUEST_BODY_SEARCH = '"Current Risk Score for Hosts"'; -export const DASHBOARD_REQUEST_BODY = { +export const dashboardRequestBody = (title: string) => ({ type: 'dashboard', - search: DASHBOARD_REQUEST_BODY_SEARCH, + search: `"${title}"`, fields: ['title'], -}; +}); -export const useRiskyHostsDashboardButtonHref = (to: string, from: string) => { +export const useDashboardButtonHref = ({ + to, + from, + title, +}: { + to: string; + from: string; + title: string; +}) => { const { dashboard, savedObjects: { client: savedObjectsClient }, @@ -25,7 +32,7 @@ export const useRiskyHostsDashboardButtonHref = (to: string, from: string) => { useEffect(() => { if (dashboard?.locator && savedObjectsClient) { - savedObjectsClient.find(DASHBOARD_REQUEST_BODY).then( + savedObjectsClient.find(dashboardRequestBody(title)).then( async (DashboardsSO?: { savedObjects?: Array<{ attributes?: SavedObjectAttributes; @@ -45,7 +52,7 @@ export const useRiskyHostsDashboardButtonHref = (to: string, from: string) => { } ); } - }, [dashboard, from, savedObjectsClient, to]); + }, [dashboard, from, savedObjectsClient, to, title]); return { buttonHref, diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts new file mode 100644 index 0000000000000..4a972bd5deb1f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -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 { i18n } from '@kbn/i18n'; +import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; +import { THREAT_HUNTING } from '../../app/translations'; +import { FEATURE, LinkItem, UserPermissions } from './types'; +import { links as hostsLinks } from '../../hosts/links'; +import { links as detectionLinks } from '../../detections/links'; +import { links as networkLinks } from '../../network/links'; +import { links as usersLinks } from '../../users/links'; +import { links as timelinesLinks } from '../../timelines/links'; +import { getCasesLinkItems } from '../../cases/links'; +import { links as managementLinks } from '../../management/links'; +import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; + +export const appLinks: Readonly = Object.freeze([ + gettingStartedLinks, + dashboardsLandingLinks, + detectionLinks, + { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + }, + timelinesLinks, + getCasesLinkItems(), + managementLinks, +]); + +export const getAppLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions) => { + // OLM team, implement async behavior here + return appLinks; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx new file mode 100644 index 0000000000000..6d8e99cd416d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './links'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts new file mode 100644 index 0000000000000..d8f6711cfc629 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -0,0 +1,421 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getAncestorLinksInfo, + getDeepLinks, + getInitialDeepLinks, + getLinkInfo, + getNavLinkItems, + needsUrlState, +} from './links'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { AppDeepLink } from '@kbn/core/public'; +import { mockGlobalState } from '../mock'; +import { NavLinkItem } from './types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; + +const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; +const mockCapabilities = { + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, + [SERVER_APP_ID]: { show: true }, +} as unknown as Capabilities; + +const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => + deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.deepLinks) { + return findDeepLink(id, deepLink.deepLinks); + } + return null; + }, null); + +const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => + navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.links) { + return findNavLink(id, deepLink.links); + } + return null; + }, null); + +// remove filter once new nav is live +const allPages = Object.values(SecurityPageName).filter( + (pageName) => + pageName !== SecurityPageName.explore && + pageName !== SecurityPageName.detections && + pageName !== SecurityPageName.investigate +); +const casesPages = [ + SecurityPageName.case, + SecurityPageName.caseConfigure, + SecurityPageName.caseCreate, +]; +const featureFlagPages = [ + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsAuthentications, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const premiumPages = [ + SecurityPageName.caseConfigure, + SecurityPageName.hostsAnomalies, + SecurityPageName.networkAnomalies, + SecurityPageName.usersAnomalies, + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const nonCasesPages = allPages.reduce( + (acc: SecurityPageName[], p) => + casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], + [] +); + +const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); +const licensePremiumMock = jest.fn().mockReturnValue(true); +const mockLicense = { + isAtLeast: licensePremiumMock, +} as unknown as LicenseService; + +describe('security app link helpers', () => { + beforeEach(() => { + mockLicense.isAtLeast = licensePremiumMock; + }); + describe('getInitialDeepLinks', () => { + it('should return all pages in the app', () => { + const links = getInitialDeepLinks(); + allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + }); + describe('getDeepLinks', () => { + it('basicLicense should return only basic links', async () => { + mockLicense.isAtLeast = licenseBasicMock; + + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', async () => { + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + return expect(findDeepLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + }); + }); + + describe('getNavLinkItems', () => { + it('basicLicense should return only basic links', () => { + mockLicense.isAtLeast = licenseBasicMock; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findNavLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', () => { + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + return expect(findNavLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + }); + }); + + describe('getAncestorLinksInfo', () => { + it('finds flattened links for hosts', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + ]); + }); + it('finds flattened links for uncommonProcesses', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + { + id: 'uncommon_processes', + path: '/hosts/uncommonProcesses', + title: 'Uncommon Processes', + }, + ]); + }); + }); + + describe('needsUrlState', () => { + it('returns true when url state exists for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hosts); + expect(needsUrl).toEqual(true); + }); + it('returns false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.landing); + expect(needsUrl).toEqual(false); + }); + }); + + describe('getLinkInfo', () => { + it('gets information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hosts); + expect(linkInfo).toEqual({ + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts new file mode 100644 index 0000000000000..290a1f3fbd820 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { get } from 'lodash'; +import { SecurityPageName } from '../../../common/constants'; +import { appLinks, getAppLinks } from './app_links'; +import { + Feature, + LinkInfo, + LinkItem, + NavLinkItem, + NormalizedLink, + NormalizedLinks, + UserPermissions, +} from './types'; + +const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.links && link.links.length + ? { + deepLinks: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createDeepLink, + }), + } + : {}), + ...(link.icon != null ? { euiIconType: link.icon } : {}), + ...(link.image != null ? { icon: link.image } : {}), + ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), + ...(link.globalNavEnabled != null + ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } + : {}), + ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), + ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +}); + +const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.description != null ? { description: link.description } : {}), + ...(link.icon != null ? { icon: link.icon } : {}), + ...(link.image != null ? { image: link.image } : {}), + ...(link.links && link.links.length + ? { + links: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createNavLinkItem, + }), + } + : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), +}); + +const hasFeaturesCapability = ( + features: Feature[] | undefined, + capabilities: Capabilities +): boolean => { + if (!features) { + return true; + } + return features.some((featureKey) => get(capabilities, featureKey, false)); +}; + +const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => + !( + linkProps != null && + // exclude link when license is basic and link is premium + ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || + // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey + (link.hideWhenExperimentalKey != null && + linkProps.enableExperimental[link.hideWhenExperimentalKey]) || + // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey + (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || + // exclude link when link is not part of enabled feature capabilities + (linkProps.capabilities != null && + !hasFeaturesCapability(link.features, linkProps.capabilities))) + ); + +export function reduceLinks({ + links, + linkProps, + formatFunction, +}: { + links: Readonly; + linkProps?: UserPermissions; + formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; +}): T[] { + return links.reduce( + (deepLinks: T[], link: LinkItem) => + isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, + [] + ); +} + +export const getInitialDeepLinks = (): AppDeepLink[] => { + return appLinks.map((link) => createDeepLink(link)); +}; + +export const getDeepLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): Promise => { + const links = await getAppLinks({ enableExperimental, license, capabilities }); + return reduceLinks({ + links, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createDeepLink, + }); +}; + +export const getNavLinkItems = ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): NavLinkItem[] => + reduceLinks({ + links: appLinks, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createNavLinkItem, + }); + +/** + * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + */ +const getNormalizedLinks = ( + currentLinks: Readonly, + parentId?: SecurityPageName +): NormalizedLinks => { + const result = currentLinks.reduce>( + (normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, + {} + ); + return result as NormalizedLinks; +}; + +/** + * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children + */ +const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); + +/** + * Returns the `NormalizedLink` from a link id parameter. + * The object reference is frozen to make sure it is not mutated by the caller. + */ +const getNormalizedLink = (id: SecurityPageName): Readonly => + Object.freeze(normalizedLinks[id]); + +/** + * Returns the `LinkInfo` from a link id parameter + */ +export const getLinkInfo = (id: SecurityPageName): LinkInfo => { + // discards the parentId and creates the linkInfo copy. + const { parentId, ...linkInfo } = getNormalizedLink(id); + return linkInfo; +}; + +/** + * Returns the `LinkInfo` of all the ancestors to the parameter id link, also included. + */ +export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { + const ancestors: LinkInfo[] = []; + let currentId: SecurityPageName | undefined = id; + while (currentId) { + const { parentId, ...linkInfo } = getNormalizedLink(currentId); + ancestors.push(linkInfo); + currentId = parentId; + } + return ancestors.reverse(); +}; + +/** + * Returns `true` if the links needs to carry the application state in the url. + * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. + */ +export const needsUrlState = (id: SecurityPageName): boolean => { + return !getNormalizedLink(id).skipUrlState; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts new file mode 100644 index 0000000000000..eea348b3df737 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Capabilities } from '@kbn/core/types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; + +export const FEATURE = { + general: `${SERVER_APP_ID}.show`, + casesRead: `${CASES_FEATURE_ID}.read_cases`, + casesCrud: `${CASES_FEATURE_ID}.crud_cases`, +}; + +export type Feature = Readonly; + +export interface UserPermissions { + enableExperimental: ExperimentalFeatures; + license?: LicenseService; + capabilities?: Capabilities; +} + +export interface LinkItem { + description?: string; + disabled?: boolean; // default false + /** + * Displays deep link when feature flag is enabled. + */ + experimentalKey?: keyof ExperimentalFeatures; + features?: Feature[]; + /** + * Hides deep link when feature flag is enabled. + */ + globalNavEnabled?: boolean; // default false + globalNavOrder?: number; + globalSearchEnabled?: boolean; + globalSearchKeywords?: string[]; + hideWhenExperimentalKey?: keyof ExperimentalFeatures; + icon?: string; + id: SecurityPageName; + image?: string; + isBeta?: boolean; + licenseType?: LicenseType; + links?: LinkItem[]; + path: string; + skipUrlState?: boolean; // defaults to false + title: string; +} + +export interface NavLinkItem { + description?: string; + icon?: string; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + path: string; + title: string; + skipUrlState?: boolean; // default to false +} + +export type LinkInfo = Omit; +export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; +export type NormalizedLinks = Record; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index 0f613aff8d456..8cb29901abdad 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -13,6 +13,10 @@ import React from 'react'; import { Ecs } from '../../../../../common/ecs'; import { mockTimelines } from '../../../../common/mock/mock_timelines_plugin'; import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; +import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../../common/components/user_privileges/user_privileges_context'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; + +jest.mock('../../../../common/components/user_privileges'); const ecsRowData: Ecs = { _id: '1', @@ -71,6 +75,7 @@ const addToNewCaseButton = '[data-test-subj="add-to-new-case-action"]'; const markAsOpenButton = '[data-test-subj="open-alert-status"]'; const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]'; const markAsClosedButton = '[data-test-subj="close-alert-status"]'; +const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]'; describe('InvestigateInResolverAction', () => { test('it render AddToCase context menu item if timelineId === TimelineId.detectionsPage', () => { @@ -107,12 +112,7 @@ describe('InvestigateInResolverAction', () => { }); test('it does NOT render AddToCase context menu item when timelineId is not in the allowed list', () => { - // In order to enable alert context menu without a timelineId, event needs to be event.kind === 'event' and agent.type === 'endpoint' - const customProps = { - ...props, - ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } }, - }; - const wrapper = mount(, { + const wrapper = mount(, { wrappingComponent: TestProviders, }); wrapper.find(actionMenuButton).simulate('click'); @@ -131,4 +131,84 @@ describe('InvestigateInResolverAction', () => { expect(wrapper.find(markAsAcknowledgedButton).first().exists()).toEqual(true); expect(wrapper.find(markAsClosedButton).first().exists()).toEqual(true); }); + + describe('AddEndpointEventFilter', () => { + const endpointEventProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['endpoint'] }, event: { kind: ['event'] } }, + }; + + describe('when users can access endpoint management', () => { + beforeEach(() => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { loading: false, canAccessEndpointManagement: true }, + }); + }); + + test('it disables AddEndpointEventFilter when timeline id is not host events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); + + test('it enables AddEndpointEventFilter when timeline id is host events page', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(false); + }); + + test('it disables AddEndpointEventFilter when timeline id is host events page but is not from endpoint', () => { + const customProps = { + ...props, + ecsRowData: { ...ecsRowData, agent: { type: ['other'] }, event: { kind: ['event'] } }, + }; + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); + }); + describe('when users can NOT access endpoint management', () => { + beforeEach(() => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + ...mockInitialUserPrivilegesState(), + endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + }); + }); + + test('it disables AddEndpointEventFilter when timeline id is host events page but cannot acces endpoint management', () => { + const wrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + + wrapper.find(actionMenuButton).simulate('click'); + expect(wrapper.find(addEndpointEventFilterButton).first().exists()).toEqual(true); + expect(wrapper.find(addEndpointEventFilterButton).first().props().disabled).toEqual(true); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index a6af9febe8b3e..1427b2b3bf388 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -88,6 +88,9 @@ const AlertContextMenuComponent: React.FC indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); + const isAgentEndpoint = useMemo(() => ecsRowData.agent?.type?.includes('endpoint'), [ecsRowData]); + + const isEndpointEvent = useMemo(() => isEvent && isAgentEndpoint, [isEvent, isAgentEndpoint]); const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); @@ -173,7 +176,14 @@ const AlertContextMenuComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx index 1a56c575057f0..4327c5a69a949 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx @@ -12,9 +12,11 @@ import { ACTION_ADD_EVENT_FILTER } from '../translations'; export const useEventFilterAction = ({ onAddEventFilterClick, disabled = false, + tooltipMessage, }: { onAddEventFilterClick: () => void; disabled?: boolean; + tooltipMessage?: string; }) => { const eventFilterActionItems = useMemo( () => [ @@ -23,11 +25,12 @@ export const useEventFilterAction = ({ data-test-subj="add-event-filter-menu-item" onClick={onAddEventFilterClick} disabled={disabled} + toolTipContent={tooltipMessage} > {ACTION_ADD_EVENT_FILTER} , ], - [onAddEventFilterClick, disabled] + [onAddEventFilterClick, disabled, tooltipMessage] ); return { eventFilterActionItems }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index bdddd8ab46207..eba1fa8238d05 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -185,6 +185,14 @@ export const ACTION_ADD_EVENT_FILTER = i18n.translate( } ); +export const ACTION_ADD_EVENT_FILTER_DISABLED_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addEventFilter.disabled.tooltip', + { + defaultMessage: + 'Endpoint event filters can be created from the Events section of the Hosts page.', + } +); + export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.addEndpointException', { diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts new file mode 100644 index 0000000000000..1cfac62d80e6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/links.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS } from '../app/translations'; +import { LinkItem, FEATURE } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.alerts, + title: ALERTS, + path: ALERTS_PATH, + features: [FEATURE.general], + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.alerts', { + defaultMessage: 'Alerts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9001, +}; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts new file mode 100644 index 0000000000000..35730291d6c74 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -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 { i18n } from '@kbn/i18n'; +import { HOSTS_PATH, SecurityPageName } from '../../common/constants'; +import { HOSTS } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.hosts, + title: HOSTS, + path: HOSTS_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.hosts', { + defaultMessage: 'Hosts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9002, + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.authentications', { + defaultMessage: 'Authentications', + }), + path: `${HOSTS_PATH}/authentications`, + hideWhenExperimentalKey: 'usersEnabled', + }, + { + id: SecurityPageName.uncommonProcesses, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.uncommonProcesses', { + defaultMessage: 'Uncommon Processes', + }), + path: `${HOSTS_PATH}/uncommonProcesses`, + }, + { + id: SecurityPageName.hostsAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${HOSTS_PATH}/anomalies`, + licenseType: 'gold', + }, + { + id: SecurityPageName.hostsEvents, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.events', { + defaultMessage: 'Events', + }), + path: `${HOSTS_PATH}/events`, + }, + { + id: SecurityPageName.hostsExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${HOSTS_PATH}/externalAlerts`, + }, + { + id: SecurityPageName.hostsRisk, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.risk', { + defaultMessage: 'Hosts by risk', + }), + path: `${HOSTS_PATH}/hostRisk`, + experimentalKey: 'riskyHostsEnabled', + }, + { + id: SecurityPageName.sessions, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.sessions', { + defaultMessage: 'Sessions', + }), + path: `${HOSTS_PATH}/sessions`, + isBeta: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/constants.ts b/x-pack/plugins/security_solution/public/hosts/pages/navigation/constants.ts new file mode 100644 index 0000000000000..f97fb13c17d15 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/constants.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 const RISKY_HOSTS_DASHBOARD_TITLE = 'Current Risk Score for Hosts'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx index b23ebb7de9bef..7dd76071056e1 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/host_risk_tab_body.tsx @@ -11,7 +11,6 @@ import styled from 'styled-components'; import { HostsComponentsQueryProps } from './types'; import * as i18n from '../translations'; -import { useRiskyHostsDashboardButtonHref } from '../../../overview/containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { HostRiskInformationButtonEmpty } from '../../components/host_risk_information'; import { HostRiskScoreQueryId, useHostRiskScore } from '../../../risk_score/containers'; import { buildHostNamesFilter } from '../../../../common/search_strategy'; @@ -19,6 +18,8 @@ import { useQueryInspector } from '../../../common/components/page/manage_query' import { RiskScoreOverTime } from '../../../common/components/risk_score_over_time'; import { TopRiskScoreContributors } from '../../../common/components/top_risk_score_contributors'; import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { useDashboardButtonHref } from '../../../common/hooks/use_dashboard_button_href'; +import { RISKY_HOSTS_DASHBOARD_TITLE } from './constants'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; @@ -31,7 +32,11 @@ const HostRiskTabBodyComponent: React.FC< hostName: string; } > = ({ hostName, startDate, endDate, setQuery, deleteQuery }) => { - const { buttonHref } = useRiskyHostsDashboardButtonHref(startDate, endDate); + const { buttonHref } = useDashboardButtonHref({ + from: startDate, + to: endDate, + title: RISKY_HOSTS_DASHBOARD_TITLE, + }); const timerange = useMemo( () => ({ diff --git a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx index af8ce9dbdaf2a..3fbe33cc0ec88 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx @@ -27,7 +27,7 @@ export const DashboardRoutes = () => ( ); export const ManageRoutes = () => ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts new file mode 100644 index 0000000000000..d941d538c80f7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + BLOCKLIST_PATH, + ENDPOINTS_PATH, + EVENT_FILTERS_PATH, + EXCEPTIONS_PATH, + HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_PATH, + POLICIES_PATH, + RULES_PATH, + SecurityPageName, + TRUSTED_APPS_PATH, +} from '../../common/constants'; +import { + BLOCKLIST, + ENDPOINTS, + EVENT_FILTERS, + EXCEPTIONS, + HOST_ISOLATION_EXCEPTIONS, + MANAGE, + POLICIES, + RULES, + TRUSTED_APPLICATIONS, +} from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.administration, + title: MANAGE, + path: MANAGEMENT_PATH, + skipUrlState: true, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.manage', { + defaultMessage: 'Manage', + }), + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES, + path: RULES_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.rules', { + defaultMessage: 'Rules', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.exceptions, + title: EXCEPTIONS, + path: EXCEPTIONS_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.exceptions', { + defaultMessage: 'Exception lists', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.endpoints, + globalNavEnabled: true, + title: ENDPOINTS, + globalNavOrder: 9006, + path: ENDPOINTS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.policies, + title: POLICIES, + path: POLICIES_PATH, + skipUrlState: true, + experimentalKey: 'policyListEnabled', + }, + { + id: SecurityPageName.trustedApps, + title: TRUSTED_APPLICATIONS, + path: TRUSTED_APPS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.eventFilters, + title: EVENT_FILTERS, + path: EVENT_FILTERS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS, + path: HOST_ISOLATION_EXCEPTIONS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.blocklist, + title: BLOCKLIST, + path: BLOCKLIST_PATH, + skipUrlState: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/network/links.ts b/x-pack/plugins/security_solution/public/network/links.ts new file mode 100644 index 0000000000000..ad209a220eebc --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/links.ts @@ -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 { i18n } from '@kbn/i18n'; +import { NETWORK_PATH, SecurityPageName } from '../../common/constants'; +import { NETWORK } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.network, + title: NETWORK, + path: NETWORK_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.network', { + defaultMessage: 'Network', + }), + ], + globalNavOrder: 9003, + links: [ + { + id: SecurityPageName.networkDns, + title: i18n.translate('xpack.securitySolution.appLinks.network.dns', { + defaultMessage: 'DNS', + }), + path: `${NETWORK_PATH}/dns`, + }, + { + id: SecurityPageName.networkHttp, + title: i18n.translate('xpack.securitySolution.appLinks.network.http', { + defaultMessage: 'HTTP', + }), + path: `${NETWORK_PATH}/http`, + }, + { + id: SecurityPageName.networkTls, + title: i18n.translate('xpack.securitySolution.appLinks.network.tls', { + defaultMessage: 'TLS', + }), + path: `${NETWORK_PATH}/tls`, + }, + { + id: SecurityPageName.networkExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.network.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${NETWORK_PATH}/external-alerts`, + }, + { + id: SecurityPageName.networkAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${NETWORK_PATH}/anomalies`, + licenseType: 'gold', + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx index 575ab0057073e..060c6b3396a6b 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx @@ -20,17 +20,18 @@ import { mockGlobalState, SUB_PLUGINS_REDUCER, } from '../../../common/mock'; -import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; + import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; import { useHostRiskScore } from '../../../risk_score/containers'; +import { useDashboardButtonHref } from '../../../common/hooks/use_dashboard_button_href'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; -jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); -const useRiskyHostsDashboardButtonHrefMock = useRiskyHostsDashboardButtonHref as jest.Mock; +jest.mock('../../../common/hooks/use_dashboard_button_href'); +const useRiskyHostsDashboardButtonHrefMock = useDashboardButtonHref as jest.Mock; useRiskyHostsDashboardButtonHrefMock.mockReturnValue({ buttonHref: '/test' }); jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx index 65c22d634cf27..5cda93859fe16 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx @@ -18,15 +18,16 @@ import { mockGlobalState, SUB_PLUGINS_REDUCER, } from '../../../common/mock'; -import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; + import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; import { mockTheme } from '../overview_cti_links/mock'; import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; +import { useDashboardButtonHref } from '../../../common/hooks/use_dashboard_button_href'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); -const useRiskyHostsDashboardButtonHrefMock = useRiskyHostsDashboardButtonHref as jest.Mock; +jest.mock('../../../common/hooks/use_dashboard_button_href'); +const useRiskyHostsDashboardButtonHrefMock = useDashboardButtonHref as jest.Mock; useRiskyHostsDashboardButtonHrefMock.mockReturnValue({ buttonHref: '/test' }); jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx index cdab0a1353e1f..91a7a3fc3fe5d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx @@ -8,9 +8,10 @@ import React, { useMemo } from 'react'; import { RiskyHostsPanelView } from './risky_hosts_panel_view'; import { LinkPanelListItem } from '../link_panel'; -import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; import { HostsRiskScore } from '../../../../common/search_strategy'; +import { useDashboardButtonHref } from '../../../common/hooks/use_dashboard_button_href'; +import { RISKY_HOSTS_DASHBOARD_TITLE } from '../../../hosts/pages/navigation/constants'; const getListItemsFromHits = (items: HostsRiskScore[]): LinkPanelListItem[] => { return items.map(({ host, risk_stats: riskStats, risk: copy }) => ({ @@ -27,7 +28,7 @@ const RiskyHostsEnabledModuleComponent: React.FC<{ to: string; }> = ({ hostRiskScore, to, from }) => { const listItems = useMemo(() => getListItemsFromHits(hostRiskScore || []), [hostRiskScore]); - const { buttonHref } = useRiskyHostsDashboardButtonHref(to, from); + const { buttonHref } = useDashboardButtonHref({ to, from, title: RISKY_HOSTS_DASHBOARD_TITLE }); const { listItemsWithLinks } = useRiskyHostsDashboardLinks(to, from, listItems); return ( diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts new file mode 100644 index 0000000000000..89f75053b3d6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/links.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + DETECTION_RESPONSE_PATH, + LANDING_PATH, + OVERVIEW_PATH, + SecurityPageName, +} from '../../common/constants'; +import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const overviewLinks: LinkItem = { + id: SecurityPageName.overview, + title: OVERVIEW, + path: OVERVIEW_PATH, + globalNavEnabled: true, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.overview', { + defaultMessage: 'Overview', + }), + ], + globalNavOrder: 9000, +}; + +export const gettingStartedLinks: LinkItem = { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.getStarted', { + defaultMessage: 'Getting started', + }), + ], + skipUrlState: true, +}; + +export const detectionResponseLinks: LinkItem = { + id: SecurityPageName.detectionAndResponse, + title: DETECTION_RESPONSE, + path: DETECTION_RESPONSE_PATH, + globalNavEnabled: false, + experimentalKey: 'detectionResponseEnabled', + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { + defaultMessage: 'Detection & Response', + }), + ], +}; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], +}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 343259d88cb76..4b49c04f295a5 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -222,6 +222,7 @@ export class Plugin implements IPlugin( ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { @@ -34,11 +35,10 @@ export const UsersKpiComponent = React.memo( description: ( <> - {/* - TODO PENDING ON USER RISK DOCUMENTATION} - */} - {i18n.LEARN_MORE} {i18n.USER_RISK_DATA} - {/* */} + {i18n.LEARN_MORE}{' '} + + {i18n.USER_RISK_DATA} + ), diff --git a/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx index 6ae647544d965..066e3b01fbdd2 100644 --- a/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/user_risk_information/index.tsx @@ -20,14 +20,16 @@ import { EuiSpacer, EuiBasicTableColumn, EuiButtonEmpty, + EuiLink, } from '@elastic/eui'; - +import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; import * as i18n from './translations'; import { useOnOpenCloseHandler } from '../../../helper_hooks'; import { RiskScore } from '../../../common/components/severity/common'; import { RiskSeverity } from '../../../../common/search_strategy'; +import { RISKY_USERS_DOC_LINK } from '../constants'; const tableColumns: Array> = [ { @@ -102,22 +104,21 @@ const UserRiskInformationFlyout = ({ handleOnClose }: { handleOnClose: () => voi items={tableItems} data-test-subj="risk-information-table" /> - {/* TODO PENDING ON USER RISK DOCUMENTATION + usersRiskScoreDocumentationLink: ( + ), }} - /> */} + /> diff --git a/x-pack/plugins/security_solution/public/users/links.ts b/x-pack/plugins/security_solution/public/users/links.ts new file mode 100644 index 0000000000000..bd7bef4af8e82 --- /dev/null +++ b/x-pack/plugins/security_solution/public/users/links.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { SecurityPageName, USERS_PATH } from '../../common/constants'; +import { USERS } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.users, + title: USERS, + path: USERS_PATH, + globalNavEnabled: true, + experimentalKey: 'usersEnabled', + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.users', { + defaultMessage: 'Users', + }), + ], + globalNavOrder: 9004, + links: [ + { + id: SecurityPageName.usersAuthentications, + title: i18n.translate('xpack.securitySolution.appLinks.users.authentications', { + defaultMessage: 'Authentications', + }), + path: `${USERS_PATH}/authentications`, + }, + { + id: SecurityPageName.usersAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.users.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${USERS_PATH}/anomalies`, + licenseType: 'gold', + }, + { + id: SecurityPageName.usersRisk, + title: i18n.translate('xpack.securitySolution.appLinks.users.risk', { + defaultMessage: 'Users by risk', + }), + path: `${USERS_PATH}/userRisk`, + experimentalKey: 'riskyUsersEnabled', + }, + { + id: SecurityPageName.usersEvents, + title: i18n.translate('xpack.securitySolution.appLinks.users.events', { + defaultMessage: 'Events', + }), + path: `${USERS_PATH}/events`, + }, + { + id: SecurityPageName.usersExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.users.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${USERS_PATH}/externalAlerts`, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx index ee37df16fd19c..bb1f73765bf59 100644 --- a/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/users/pages/navigation/user_risk_tab_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -19,6 +19,7 @@ import { UserRiskScoreQueryId, useUserRiskScore } from '../../../risk_score/cont import { buildUserNamesFilter } from '../../../../common/search_strategy'; import { UsersComponentsQueryProps } from './types'; import { UserRiskInformationButtonEmpty } from '../../components/user_risk_information'; +import { useDashboardButtonHref } from '../../../common/hooks/use_dashboard_button_href'; const QUERY_ID = UserRiskScoreQueryId.USER_DETAILS_RISK_SCORE; @@ -26,11 +27,19 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` margin-top: ${({ theme }) => theme.eui.paddingSizes.l}; `; +const RISKY_USERS_DASHBOARD_TITLE = 'User Risk Score (Start Here)'; + const UserRiskTabBodyComponent: React.FC< Pick & { userName: string; } > = ({ userName, startDate, endDate, setQuery, deleteQuery }) => { + const { buttonHref } = useDashboardButtonHref({ + to: endDate, + from: startDate, + title: RISKY_USERS_DASHBOARD_TITLE, + }); + const timerange = useMemo( () => ({ from: startDate, @@ -104,7 +113,6 @@ const UserRiskTabBodyComponent: React.FC<
- {/* // TODO PENDING ON USER RISK DOCUMENTATION {i18n.VIEW_DASHBOARD_BUTTON} - */} +
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.test.ts new file mode 100644 index 0000000000000..9c6ef9b3a2c57 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + ScopedClusterClientMock, + elasticsearchServiceMock, + savedObjectsClientMock, + httpServerMock, +} from '@kbn/core/server/mocks'; +import type { KibanaResponseFactory, SavedObjectsClientContract } from '@kbn/core/server'; +import { createMockEndpointAppContext, createRouteHandlerContext } from '../../mocks'; +import { applyActionsEsSearchMock } from '../../services/actions/mocks'; +import { requestContextMock } from '../../../lib/detection_engine/routes/__mocks__'; +import { getActionDetailsRequestHandler } from './details'; +import { NotFoundError } from '../../errors'; +import { ActionDetailsRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; + +describe('when calling the Action Details route handler', () => { + let mockScopedEsClient: ScopedClusterClientMock; + let mockSavedObjectClient: jest.Mocked; + let mockResponse: jest.Mocked; + let actionDetailsRouteHandler: ReturnType; + + beforeEach(() => { + mockScopedEsClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + actionDetailsRouteHandler = getActionDetailsRequestHandler(createMockEndpointAppContext()); + }); + + it('should call service using action id from request', async () => { + applyActionsEsSearchMock(mockScopedEsClient.asInternalUser); + + const mockContext = requestContextMock.convertContext( + createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient) + ); + const mockRequest = httpServerMock.createKibanaRequest< + TypeOf, + never, + never + >({ + params: { action_id: 'a-b-c' }, + }); + + await actionDetailsRouteHandler(mockContext, mockRequest, mockResponse); + + expect(mockScopedEsClient.asInternalUser.search).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { + query: { + bool: { + filter: expect.arrayContaining([{ term: { action_id: 'a-b-c' } }]), + }, + }, + }, + }), + expect.any(Object) + ); + + expect(mockResponse.ok).toHaveBeenCalled(); + }); + + it('should respond with 404 if action id not found', async () => { + applyActionsEsSearchMock( + mockScopedEsClient.asInternalUser, + new EndpointActionGenerator().toEsSearchResponse([]) + ); + + const mockContext = requestContextMock.convertContext( + createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient) + ); + const mockRequest = httpServerMock.createKibanaRequest< + TypeOf, + never, + never + >({ + params: { action_id: '123' }, + }); + + await actionDetailsRouteHandler(mockContext, mockRequest, mockResponse); + + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: expect.any(NotFoundError), + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.ts new file mode 100644 index 0000000000000..a5ba924a42728 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler } from '@kbn/core/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; +import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants'; +import { ActionDetailsRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { withEndpointAuthz } from '../with_endpoint_authz'; +import { getActionDetailsById } from '../../services'; +import { errorHandler } from '../error_handler'; + +/** + * Registers the route for handling retrieval of Action Details + * @param router + * @param endpointContext + */ +export const registerActionDetailsRoutes = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + // Details for a given action id + router.get( + { + path: ACTION_DETAILS_ROUTE, + validate: ActionDetailsRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { all: ['canAccessEndpointManagement'] }, + endpointContext.logFactory.get('hostIsolationDetails'), + getActionDetailsRequestHandler(endpointContext) + ) + ); +}; + +export const getActionDetailsRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + never, + never, + SecuritySolutionRequestHandlerContext +> => { + return async (context, req, res) => { + try { + return res.ok({ + body: { + data: await getActionDetailsById( + ( + await context.core + ).elasticsearch.client.asInternalUser, + req.params.action_id + ), + }, + }); + } catch (error) { + return errorHandler(endpointContext.logFactory.get('EndpointActionDetails'), res, error); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 3245369b56e40..baa9440ae8d0c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { registerActionDetailsRoutes } from './details'; import { SecuritySolutionPluginRouter } from '../../../types'; import { EndpointAppContext } from '../../types'; import { registerHostIsolationRoutes } from './isolation'; @@ -22,4 +23,5 @@ export function registerActionRoutes( registerHostIsolationRoutes(router, endpointContext); registerActionStatusRoutes(router, endpointContext); registerActionAuditLogRoutes(router, endpointContext); + registerActionDetailsRoutes(router, endpointContext); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 69954dbd7e7a7..c640f56efb512 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -190,6 +190,7 @@ export const isolationRequestHandler = function ( body: { ...doc, }, + refresh: 'wait_for', }, { meta: true } ); @@ -221,6 +222,7 @@ export const isolationRequestHandler = function ( timeout: 300, // 5 minutes user_id: doc.user.id, }, + refresh: 'wait_for', }, { meta: true } ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts index bfd17cccb3d0d..61b2b9c56f5b0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -18,12 +18,13 @@ import { getPendingActionCounts } from '../../services'; import { withEndpointAuthz } from '../with_endpoint_authz'; /** - * Registers routes for checking status of endpoints based on pending actions + * Registers routes for checking status of actions */ export function registerActionStatusRoutes( router: SecuritySolutionPluginRouter, endpointContext: EndpointAppContext ) { + // Summary of action status for a given list of endpoints router.get( { path: ACTION_STATUS_ROUTE, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts new file mode 100644 index 0000000000000..065ab835dbf99 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server'; +import { CustomHttpRequestError } from '../../utils/custom_http_request_error'; +import { NotFoundError } from '../errors'; +import { EndpointHostUnEnrolledError } from '../services/metadata'; + +/** + * Default Endpoint Routes error handler + * @param logger + * @param res + * @param error + */ +export const errorHandler = ( + logger: Logger, + res: KibanaResponseFactory, + error: E +): IKibanaResponse => { + logger.error(error); + + if (error instanceof CustomHttpRequestError) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + if (error instanceof NotFoundError) { + return res.notFound({ body: error }); + } + + if (error instanceof EndpointHostUnEnrolledError) { + return res.badRequest({ body: error }); + } + + // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error + throw error; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 1b86924101b68..f9aa361e71f32 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -7,15 +7,14 @@ import { TypeOf } from '@kbn/config-schema'; import { - IKibanaResponse, IScopedClusterClient, - KibanaResponseFactory, Logger, RequestHandler, SavedObjectsClientContract, } from '@kbn/core/server'; import { PackagePolicy } from '@kbn/fleet-plugin/common/types/models'; import { AgentNotFoundError } from '@kbn/fleet-plugin/server'; +import { errorHandler } from '../error_handler'; import { HostInfo, HostMetadata, @@ -33,9 +32,6 @@ import { findAgentIdsByStatus } from './support/agent_status'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { fleetAgentStatusToEndpointHostStatus } from '../../utils'; import { queryResponseToHostListResult } from './support/query_strategies'; -import { NotFoundError } from '../../errors'; -import { EndpointHostUnEnrolledError } from '../../services/metadata'; -import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; import { GetMetadataListRequestQuery } from '../../../../common/endpoint/schema/metadata'; import { ENDPOINT_DEFAULT_PAGE, @@ -56,32 +52,6 @@ export const getLogger = (endpointAppContext: EndpointAppContext): Logger => { return endpointAppContext.logFactory.get('metadata'); }; -const errorHandler = ( - logger: Logger, - res: KibanaResponseFactory, - error: E -): IKibanaResponse => { - logger.error(error); - - if (error instanceof CustomHttpRequestError) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); - } - - if (error instanceof NotFoundError) { - return res.notFound({ body: error }); - } - - if (error instanceof EndpointHostUnEnrolledError) { - return res.badRequest({ body: error }); - } - - // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error - throw error; -}; - export function getMetadataListRequestHandler( endpointAppContext: EndpointAppContext, logger: Logger diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts new file mode 100644 index 0000000000000..b977009b15315 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EndpointAction, + EndpointActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { getActionDetailsById } from '..'; +import { NotFoundError } from '../../errors'; +import { + applyActionsEsSearchMock, + createActionRequestsEsSearchResultsMock, + createActionResponsesEsSearchResultsMock, +} from './mocks'; + +describe('When using `getActionDetailsById()', () => { + let esClient: ElasticsearchClientMock; + let endpointActionGenerator: EndpointActionGenerator; + let actionRequests: estypes.SearchResponse; + let actionResponses: estypes.SearchResponse; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; + endpointActionGenerator = new EndpointActionGenerator('seed'); + + actionRequests = createActionRequestsEsSearchResultsMock(); + actionResponses = createActionResponsesEsSearchResultsMock(); + + applyActionsEsSearchMock(esClient, actionRequests, actionResponses); + }); + + it('should return expected output', async () => { + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual({ + agents: ['agent-a'], + command: 'isolate', + completedAt: '2022-04-30T16:08:47.449Z', + id: '123', + isCompleted: true, + isExpired: false, + logEntries: [ + { + item: { + data: { + '@timestamp': '2022-04-27T16:08:47.449Z', + action_id: '123', + agents: ['agent-a'], + data: { + command: 'isolate', + comment: '5wb6pu6kh2xix5i', + }, + expiration: '2022-04-29T16:08:47.449Z', + input_type: 'endpoint', + type: 'INPUT_ACTION', + user_id: 'elastic', + }, + id: '44d8b915-c69c-4c48-8c86-b57d0bd631d0', + }, + type: 'fleetAction', + }, + { + item: { + data: { + '@timestamp': '2022-04-30T16:08:47.449Z', + action_data: { + command: 'unisolate', + comment: '', + }, + action_id: '123', + agent_id: 'agent-a', + completed_at: '2022-04-30T16:08:47.449Z', + error: '', + started_at: expect.any(String), + }, + id: expect.any(String), + }, + type: 'fleetResponse', + }, + { + item: { + data: { + '@timestamp': '2022-04-30T16:08:47.449Z', + EndpointActions: { + action_id: '123', + completed_at: '2022-04-30T16:08:47.449Z', + data: { + command: 'unisolate', + comment: '', + }, + started_at: expect.any(String), + }, + agent: { + id: 'agent-a', + }, + }, + id: expect.any(String), + }, + type: 'response', + }, + ], + startedAt: '2022-04-27T16:08:47.449Z', + }); + }); + + it('should use expected filters when querying for Action Request', async () => { + await getActionDetailsById(esClient, '123'); + + expect(esClient.search).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { + query: { + bool: { + filter: [ + { term: { action_id: '123' } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ], + }, + }, + }, + }), + expect.any(Object) + ); + }); + + it('should throw an error if action id does not exist', async () => { + actionRequests.hits.hits = []; + (actionResponses.hits.total as estypes.SearchTotalHits).value = 0; + actionRequests = endpointActionGenerator.toEsSearchResponse([]); + + await expect(getActionDetailsById(esClient, '123')).rejects.toBeInstanceOf(NotFoundError); + }); + + it('should have `isExpired` of `true` if NOT complete and expiration is in the past', async () => { + (actionRequests.hits.hits[0]._source as EndpointAction).expiration = `2021-04-30T16:08:47.449Z`; + actionResponses.hits.hits.pop(); // remove the endpoint response + + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual( + expect.objectContaining({ + isExpired: true, + isCompleted: false, + }) + ); + }); + + it('should have `isExpired` of `false` if complete and expiration is in the past', async () => { + (actionRequests.hits.hits[0]._source as EndpointAction).expiration = `2021-04-30T16:08:47.449Z`; + + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual( + expect.objectContaining({ + isExpired: false, + isCompleted: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts new file mode 100644 index 0000000000000..768c015b53a91 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from '@kbn/core/server'; +import { getActionCompletionInfo, mapToNormalizedActionRequest } from './utils'; +import { + ActionDetails, + ActivityLogAction, + ActivityLogActionResponse, + EndpointAction, + EndpointActionResponse, + EndpointActivityLogAction, + EndpointActivityLogActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { + ACTION_REQUEST_INDICES, + ACTION_RESPONSE_INDICES, + catchAndWrapError, + categorizeActionResults, + categorizeResponseResults, + getUniqueLogData, +} from '../../utils'; +import { EndpointError } from '../../../../common/endpoint/errors'; +import { NotFoundError } from '../../errors'; +import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; + +export const getActionDetailsById = async ( + esClient: ElasticsearchClient, + actionId: string +): Promise => { + let actionRequestsLogEntries: Array; + + let normalizedActionRequest: ReturnType | undefined; + let actionResponses: Array; + + try { + // Get both the Action Request(s) and action Response(s) + const [actionRequestEsSearchResults, actionResponsesEsSearchResults] = await Promise.all([ + // Get the action request(s) + esClient + .search( + { + index: ACTION_REQUEST_INDICES, + body: { + query: { + bool: { + filter: [ + { term: { action_id: actionId } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ], + }, + }, + }, + }, + { + ignore: [404], + } + ) + .catch(catchAndWrapError), + + // Get the Action Response(s) + esClient + .search( + { + index: ACTION_RESPONSE_INDICES, + size: ACTIONS_SEARCH_PAGE_SIZE, + body: { + query: { + bool: { + filter: [{ term: { action_id: actionId } }], + }, + }, + }, + }, + { ignore: [404] } + ) + .catch(catchAndWrapError), + ]); + + actionRequestsLogEntries = getUniqueLogData( + categorizeActionResults({ + results: actionRequestEsSearchResults?.hits?.hits ?? [], + }) + ) as Array; + + // Multiple Action records could have been returned, but we only really + // need one since they both hold similar data + const actionDoc = actionRequestsLogEntries[0]?.item.data; + + if (actionDoc) { + normalizedActionRequest = mapToNormalizedActionRequest(actionDoc); + } + + actionResponses = categorizeResponseResults({ + results: actionResponsesEsSearchResults?.hits?.hits ?? [], + }) as Array; + } catch (error) { + throw new EndpointError(error.message, error); + } + + // If action id was not found, error out + if (!normalizedActionRequest) { + throw new NotFoundError(`Action with id '${actionId}' not found.`); + } + + const { isCompleted, completedAt } = getActionCompletionInfo( + normalizedActionRequest.agents, + actionResponses + ); + + const actionDetails: ActionDetails = { + id: actionId, + agents: normalizedActionRequest.agents, + command: normalizedActionRequest.command, + startedAt: normalizedActionRequest.createdAt, + logEntries: [...actionRequestsLogEntries, ...actionResponses], + isCompleted, + completedAt, + isExpired: !isCompleted && normalizedActionRequest.expiration < new Date().toISOString(), + }; + + return actionDetails; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts similarity index 96% rename from x-pack/plugins/security_solution/server/endpoint/services/actions.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts index 080ee6e588b03..59060e4e56952 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts @@ -9,8 +9,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TransportResult } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; -import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../common/endpoint/constants'; -import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants'; +import { SecuritySolutionRequestHandlerContext } from '../../../types'; import { ActivityLog, ActivityLogEntry, @@ -19,7 +19,7 @@ import { EndpointActionResponse, EndpointPendingActions, LogsEndpointActionResponse, -} from '../../../common/endpoint/types'; +} from '../../../../common/endpoint/types'; import { catchAndWrapError, categorizeActionResults, @@ -28,8 +28,9 @@ import { getActionResponsesResult, getTimeSortedData, getUniqueLogData, -} from '../utils'; -import { EndpointMetadataService } from './metadata'; +} from '../../utils'; +import { EndpointMetadataService } from '../metadata'; +import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; const PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME = 300000; // 300k ms === 5 minutes @@ -194,7 +195,7 @@ export const getPendingActionCounts = async ( .search( { index: AGENT_ACTIONS_INDEX, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, from: 0, body: { query: { @@ -294,7 +295,7 @@ const hasEndpointResponseDoc = async ({ .search( { index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, body: { query: { bool: { @@ -336,7 +337,7 @@ const fetchActionResponses = async ( .search( { index: AGENT_ACTIONS_RESULTS_INDEX, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, from: 0, body: { query: { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.ts new file mode 100644 index 0000000000000..43907dce85a1b --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.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. + */ + +/** + * The Page Size to be used when searching against the Actions indexes (both requests and responses) + */ +export const ACTIONS_SEARCH_PAGE_SIZE = 10000; diff --git a/x-pack/plugins/lens/common/expressions/expression_types/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts similarity index 65% rename from x-pack/plugins/lens/common/expressions/expression_types/index.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts index 78821e429fa8f..33d7892891cb8 100644 --- a/x-pack/plugins/lens/common/expressions/expression_types/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { lensMultitable } from './lens_multitable'; -export type { LensMultitableExpressionTypeDefinition } from './lens_multitable'; +export * from './actions'; +export { getActionDetailsById } from './action_details_by_id'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts new file mode 100644 index 0000000000000..0670b6e3aa433 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { + EndpointAction, + EndpointActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { + ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + ENDPOINT_ACTIONS_INDEX, +} from '../../../../common/endpoint/constants'; + +export const createActionRequestsEsSearchResultsMock = (): estypes.SearchResponse< + EndpointAction | LogsEndpointAction +> => { + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const fleetActionGenerator = new FleetActionGenerator('seed'); + + return endpointActionGenerator.toEsSearchResponse([ + fleetActionGenerator.generateActionEsHit({ + action_id: '123', + agents: ['agent-a'], + '@timestamp': '2022-04-27T16:08:47.449Z', + }), + endpointActionGenerator.generateActionEsHit({ + EndpointActions: { action_id: '123' }, + agent: { id: 'agent-a' }, + '@timestamp': '2022-04-27T16:08:47.449Z', + }), + ]); +}; + +export const createActionResponsesEsSearchResultsMock = (): estypes.SearchResponse< + LogsEndpointActionResponse | EndpointActionResponse +> => { + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const fleetActionGenerator = new FleetActionGenerator('seed'); + + return endpointActionGenerator.toEsSearchResponse< + LogsEndpointActionResponse | EndpointActionResponse + >([ + fleetActionGenerator.generateResponseEsHit({ + action_id: '123', + agent_id: 'agent-a', + error: '', + '@timestamp': '2022-04-30T16:08:47.449Z', + }), + endpointActionGenerator.generateResponseEsHit({ + agent: { id: 'agent-a' }, + EndpointActions: { action_id: '123' }, + '@timestamp': '2022-04-30T16:08:47.449Z', + }), + ]); +}; + +/** + * Applies a mock implementation to the `esClient.search()` method that will return action requests or responses + * depending on what indexes the `.search()` was called with. + * @param esClient + * @param actionRequests + * @param actionResponses + */ +export const applyActionsEsSearchMock = ( + esClient: ElasticsearchClientMock, + actionRequests: estypes.SearchResponse< + EndpointAction | LogsEndpointAction + > = createActionRequestsEsSearchResultsMock(), + actionResponses: estypes.SearchResponse< + LogsEndpointActionResponse | EndpointActionResponse + > = createActionResponsesEsSearchResultsMock() +) => { + const priorSearchMockImplementation = esClient.search.getMockImplementation(); + + esClient.search.mockImplementation(async (...args) => { + const params = args[0] ?? {}; + const indexes = Array.isArray(params.index) ? params.index : [params.index]; + + if (indexes.includes(AGENT_ACTIONS_INDEX) || indexes.includes(ENDPOINT_ACTIONS_INDEX)) { + return actionRequests; + } else if ( + indexes.includes(AGENT_ACTIONS_RESULTS_INDEX) || + indexes.includes(ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN) + ) { + return actionResponses; + } + + if (priorSearchMockImplementation) { + return priorSearchMockImplementation(...args); + } + + return new EndpointActionGenerator().toEsSearchResponse([]); + }); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts new file mode 100644 index 0000000000000..3071c8a417c6a --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { + getActionCompletionInfo, + isLogsEndpointAction, + isLogsEndpointActionResponse, + mapToNormalizedActionRequest, +} from './utils'; +import type { + ActivityLogActionResponse, + EndpointActivityLogActionResponse, +} from '../../../../common/endpoint/types'; + +describe('When using Actions service utilities', () => { + let fleetActionGenerator: FleetActionGenerator; + let endpointActionGenerator: EndpointActionGenerator; + + beforeEach(() => { + fleetActionGenerator = new FleetActionGenerator('seed'); + endpointActionGenerator = new EndpointActionGenerator('seed'); + }); + + describe('#isLogsEndpointAction()', () => { + it('should return `true` for a `LogsEndpointAction` (endpoint action request)', () => { + expect(isLogsEndpointAction(endpointActionGenerator.generate())).toBe(true); + }); + + it('should return `false` for an `EndpointAction` (fleet action request)', () => { + expect(isLogsEndpointAction(fleetActionGenerator.generate())).toBe(false); + }); + }); + + describe('#isLogsEndpointActionResponse()', () => { + it('should return `true` for a `LogsEndpointActionResponse` (response sent by endpoint)', () => { + expect(isLogsEndpointActionResponse(endpointActionGenerator.generateResponse())).toBe(true); + }); + + it('should return `false` for a `EndpointActionResponse` (response sent by fleet agent)', () => { + expect(isLogsEndpointActionResponse(fleetActionGenerator.generateResponse())).toBe(false); + }); + }); + + describe('#mapToNormalizedActionRequest()', () => { + it('normalizes an `EndpointAction` (those stored in Fleet index)', () => { + expect( + mapToNormalizedActionRequest( + fleetActionGenerator.generate({ + '@timestamp': '2022-04-27T16:08:47.449Z', + }) + ) + ).toEqual({ + agents: ['6e6796b0-af39-4f12-b025-fcb06db499e5'], + command: 'isolate', + comment: 'isolate', + createdAt: '2022-04-27T16:08:47.449Z', + createdBy: 'elastic', + expiration: '2022-04-29T16:08:47.449Z', + id: '90d62689-f72d-4a05-b5e3-500cad0dc366', + type: 'ACTION_REQUEST', + }); + }); + + it('normalizes a `LogsEndpointAction` (those stored in Endpoint index)', () => { + expect( + mapToNormalizedActionRequest( + endpointActionGenerator.generate({ + '@timestamp': '2022-04-27T16:08:47.449Z', + }) + ) + ).toEqual({ + agents: ['90d62689-f72d-4a05-b5e3-500cad0dc366'], + command: 'isolate', + comment: 'isolate', + createdAt: '2022-04-27T16:08:47.449Z', + createdBy: 'Shanel', + expiration: '2022-05-10T16:08:47.449Z', + id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + type: 'ACTION_REQUEST', + }); + }); + }); + + describe('#getAction CompletionInfo()', () => { + const COMPLETED_AT = '2022-05-05T18:53:18.836Z'; + const NOT_COMPLETED_OUTPUT = Object.freeze({ + isCompleted: false, + completed: undefined, + }); + + it('should show complete `false` if no action ids', () => { + expect(getActionCompletionInfo([], [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `false` if no responses', () => { + expect(getActionCompletionInfo(['123'], [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `false` if no Endpoint response', () => { + expect( + getActionCompletionInfo( + ['123'], + [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { action_id: '123' } }, + }), + ] + ) + ).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `true` with completion date if Endpoint Response received', () => { + expect( + getActionCompletionInfo( + ['123'], + [ + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': COMPLETED_AT, + agent: { id: '123' }, + EndpointActions: { completed_at: COMPLETED_AT }, + }, + }, + }), + ] + ) + ).toEqual({ isCompleted: true, completedAt: COMPLETED_AT }); + }); + + describe('with multiple agent ids', () => { + let agentIds: string[]; + let action123Responses: Array; + let action456Responses: Array; + let action789Responses: Array; + + beforeEach(() => { + agentIds = ['123', '456', '789']; + action123Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '123', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': '2022-01-05T19:27:23.816Z', + agent: { id: '123' }, + EndpointActions: { completed_at: '2022-01-05T19:27:23.816Z' }, + }, + }, + }), + ]; + + action456Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '456', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': COMPLETED_AT, + agent: { id: '456' }, + EndpointActions: { completed_at: COMPLETED_AT }, + }, + }, + }), + ]; + + action789Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '789', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': '2022-03-05T19:27:23.816Z', + agent: { id: '789' }, + EndpointActions: { completed_at: '2022-03-05T19:27:23.816Z' }, + }, + }, + }), + ]; + }); + + it('should show complete as `false` if no responses', () => { + expect(getActionCompletionInfo(agentIds, [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should complete as `false` if at least one agent id is has not received a response', () => { + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + + // Action id: 456 === Not complete (only fleet response) + action456Responses[0], + + ...action789Responses, + ]) + ).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `true` if all agent response were received', () => { + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + ...action456Responses, + ...action789Responses, + ]) + ).toEqual({ isCompleted: true, completedAt: COMPLETED_AT }); + }); + + it('should complete as `true` if one agent only received a fleet response with error on it', () => { + action456Responses[0].item.data.error = 'something is no good'; + action456Responses[0].item.data['@timestamp'] = '2022-05-06T12:50:19.747Z'; + + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + + // Action id: 456 === is complete with only a fleet response that has `error` + action456Responses[0], + + ...action789Responses, + ]) + ).toEqual({ isCompleted: true, completedAt: '2022-05-06T12:50:19.747Z' }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts new file mode 100644 index 0000000000000..cf4c2ba6a718d --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ActivityLogActionResponse, + EndpointAction, + EndpointActionResponse, + EndpointActivityLogActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; + +/** + * Type guard to check if a given Action is in the shape of the Endpoint Action. + * @param item + */ +export const isLogsEndpointAction = ( + item: LogsEndpointAction | EndpointAction +): item is LogsEndpointAction => { + return 'EndpointActions' in item && 'user' in item && 'agent' in item && '@timestamp' in item; +}; + +/** + * Type guard to track if a given action response is in the shape of the Endpoint Action Response (from the endpoint index) + * @param item + */ +export const isLogsEndpointActionResponse = ( + item: EndpointActionResponse | LogsEndpointActionResponse +): item is LogsEndpointActionResponse => { + return 'EndpointActions' in item && 'agent' in item; +}; + +interface NormalizedActionRequest { + id: string; + type: 'ACTION_REQUEST'; + expiration: string; + agents: string[]; + createdBy: string; + createdAt: string; + command: string; + comment?: string; +} + +/** + * Given an Action record - either a fleet action or an endpoint action - this utility + * will return a normalized data structure based on those two types, which + * will avoid us having to either cast or do type guards against the two different + * types of action request. + */ +export const mapToNormalizedActionRequest = ( + actionRequest: EndpointAction | LogsEndpointAction +): NormalizedActionRequest => { + if (isLogsEndpointAction(actionRequest)) { + return { + agents: Array.isArray(actionRequest.agent.id) + ? actionRequest.agent.id + : [actionRequest.agent.id], + command: actionRequest.EndpointActions.data.command, + comment: actionRequest.EndpointActions.data.command, + type: 'ACTION_REQUEST', + id: actionRequest.EndpointActions.action_id, + expiration: actionRequest.EndpointActions.expiration, + createdBy: actionRequest.user.id, + createdAt: actionRequest['@timestamp'], + }; + } + + // Else, it's a Fleet Endpoint Action record + return { + agents: actionRequest.agents, + command: actionRequest.data.command, + comment: actionRequest.data.command, + type: 'ACTION_REQUEST', + id: actionRequest.action_id, + expiration: actionRequest.expiration, + createdBy: actionRequest.user_id, + createdAt: actionRequest['@timestamp'], + }; +}; + +interface ActionCompletionInfo { + isCompleted: boolean; + completedAt: undefined | string; +} + +export const getActionCompletionInfo = ( + /** List of agents that the action was sent to */ + agentIds: string[], + /** List of action Log responses received for the action */ + actionResponses: Array +): ActionCompletionInfo => { + const completedInfo: ActionCompletionInfo = { + isCompleted: Boolean(agentIds.length), + completedAt: undefined, + }; + + const responsesByAgentId = mapActionResponsesByAgentId(actionResponses); + + for (const agentId of agentIds) { + if (!responsesByAgentId[agentId] || !responsesByAgentId[agentId].isCompleted) { + completedInfo.isCompleted = false; + break; + } + } + + // If completed, then get the completed at date + if (completedInfo.isCompleted) { + for (const normalizedAgentResponse of Object.values(responsesByAgentId)) { + if ( + !completedInfo.completedAt || + completedInfo.completedAt < (normalizedAgentResponse.completedAt ?? '') + ) { + completedInfo.completedAt = normalizedAgentResponse.completedAt; + } + } + } + + return completedInfo; +}; + +interface NormalizedAgentActionResponse { + isCompleted: boolean; + completedAt: undefined | string; + fleetResponse: undefined | ActivityLogActionResponse; + endpointResponse: undefined | EndpointActivityLogActionResponse; +} + +type ActionResponseByAgentId = Record; + +/** + * Given a list of Action Responses, it will return a Map where keys are the Agent ID and + * value is a object having information about the action response's associated with that agent id + * @param actionResponses + */ +const mapActionResponsesByAgentId = ( + actionResponses: Array +): ActionResponseByAgentId => { + const response: ActionResponseByAgentId = {}; + + for (const actionResponse of actionResponses) { + if (actionResponse.type === 'fleetResponse' || actionResponse.type === 'response') { + const agentId = getAgentIdFromActionResponse(actionResponse); + let thisAgentActionResponses = response[agentId]; + + if (!thisAgentActionResponses) { + response[agentId] = { + isCompleted: false, + completedAt: undefined, + fleetResponse: undefined, + endpointResponse: undefined, + }; + + thisAgentActionResponses = response[agentId]; + } + + if (actionResponse.type === 'fleetResponse') { + thisAgentActionResponses.fleetResponse = actionResponse; + } else { + thisAgentActionResponses.endpointResponse = actionResponse; + } + + thisAgentActionResponses.isCompleted = + // Action is complete if an Endpoint Action Response was received + Boolean(thisAgentActionResponses.endpointResponse) || + // OR: + // If we did not have an endpoint response and the Fleet response has `error`, then + // action is complete. Elastic Agent was unable to deliver the action request to the + // endpoint, so we are unlikely to ever receive an Endpoint Response. + Boolean(thisAgentActionResponses.fleetResponse?.item.data.error); + + if (thisAgentActionResponses.isCompleted) { + if (thisAgentActionResponses.endpointResponse) { + thisAgentActionResponses.completedAt = + thisAgentActionResponses.endpointResponse?.item.data['@timestamp']; + } else if ( + thisAgentActionResponses.fleetResponse && + thisAgentActionResponses.fleetResponse?.item.data.error + ) { + // Check if perhaps the Fleet action response returned an error, in which case, the Fleet Agent + // failed to deliver the Action to the Endpoint. If that's the case, we are not going to get + // a Response from endpoint, thus mark the Action as completed and use the Fleet Message's + // timestamp for the complete data/time. + thisAgentActionResponses.isCompleted = true; + thisAgentActionResponses.completedAt = + thisAgentActionResponses.fleetResponse?.item.data['@timestamp']; + } + } + } + } + + return response; +}; + +/** + * Given an Action response, this will return the Agent ID for that action response. + * @param actionResponse + */ +const getAgentIdFromActionResponse = ( + actionResponse: ActivityLogActionResponse | EndpointActivityLogActionResponse +): string => { + const responseData = actionResponse.item.data; + + if (isLogsEndpointActionResponse(responseData)) { + return Array.isArray(responseData.agent.id) ? responseData.agent.id[0] : responseData.agent.id; + } + + return responseData.agent_id; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts index 3a1e25c32b683..dc326f4fa4631 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts @@ -30,11 +30,14 @@ import { LogsEndpointActionResponse, ActivityLogEntry, } from '../../../common/endpoint/types'; -import { doesLogsEndpointActionsIndexExist } from '.'; +import { doesLogsEndpointActionsIndexExist } from './yes_no_data_stream'; -const actionsIndices = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; +export const ACTION_REQUEST_INDICES = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; // search all responses indices irrelevant of namespace -const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN]; +export const ACTION_RESPONSE_INDICES = [ + AGENT_ACTIONS_RESULTS_INDEX, + ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, +]; export const logsEndpointActionsRegex = new RegExp(`(^\.ds-\.logs-endpoint\.actions-default-).+`); // matches index names like .ds-.logs-endpoint.action.responses-name_space---suffix-2022.01.25-000001 export const logsEndpointResponsesRegex = new RegExp( @@ -173,7 +176,7 @@ export const getActionRequestsResult = async ({ }); const actionsSearchQuery: SearchRequest = { - index: hasLogsEndpointActionsIndex ? actionsIndices : AGENT_ACTIONS_INDEX, + index: hasLogsEndpointActionsIndex ? ACTION_REQUEST_INDICES : AGENT_ACTIONS_INDEX, size, from, body: { @@ -238,7 +241,9 @@ export const getActionResponsesResult = async ({ }); const responsesSearchQuery: SearchRequest = { - index: hasLogsEndpointActionResponsesIndex ? responseIndices : AGENT_ACTIONS_RESULTS_INDEX, + index: hasLogsEndpointActionResponsesIndex + ? ACTION_RESPONSE_INDICES + : AGENT_ACTIONS_RESULTS_INDEX, size: 1000, body: { query: { diff --git a/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx index fd5bc3fa13e86..942d1606564b7 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx @@ -36,7 +36,7 @@ export const ProcessTreeLoadMoreButton = ({ isLoading={isFetching} > {text} - {eventsRemaining !== 0 && ( + {eventsRemaining > 0 && ( import('./lazy_wrapper/duration_anomaly')); export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ @@ -34,6 +34,7 @@ export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ description, validate: () => ({ errors: {} }), defaultActionMessage, + defaultRecoveryMessage, requiresAppContext: true, format: ({ fields }) => ({ reason: fields[ALERT_REASON] || '', diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.test.ts b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.test.ts index 2f67219ac1ae5..c4d02806b5913 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.test.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.test.ts @@ -202,7 +202,8 @@ describe('monitor status alert type', () => { }) ).toMatchInlineSnapshot(` Object { - "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}", + "defaultActionMessage": "Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}", + "defaultRecoveryMessage": "Alert for monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} has recovered", "description": "Alert when a monitor is down or an availability threshold is breached.", "documentationUrl": [Function], "format": [Function], diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.tsx index 0361e6408e43b..f7584cb04320e 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/monitor_status.tsx @@ -23,7 +23,7 @@ import { getMonitorRouteFromMonitorId } from '../../../../common/utils/get_monit import { MonitorStatusTranslations } from '../../../../common/translations'; import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts'; -const { defaultActionMessage, description } = MonitorStatusTranslations; +const { defaultActionMessage, defaultRecoveryMessage, description } = MonitorStatusTranslations; const MonitorStatusAlert = React.lazy(() => import('./lazy_wrapper/monitor_status')); @@ -54,6 +54,7 @@ export const initMonitorStatusAlertType: AlertTypeInitializer = ({ return validateFunc ? validateFunc(ruleParams) : ({} as ValidationResult); }, defaultActionMessage, + defaultRecoveryMessage, requiresAppContext: false, format: ({ fields }) => ({ reason: fields[ALERT_REASON] || '', diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx index 2c1238028ccf5..b9ab025ecc021 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/lib/alert_types/tls.tsx @@ -14,7 +14,7 @@ import { AlertTypeInitializer } from '.'; import { CERTIFICATES_ROUTE } from '../../../../common/constants/ui'; -const { defaultActionMessage, description } = TlsTranslations; +const { defaultActionMessage, defaultRecoveryMessage, description } = TlsTranslations; const TLSAlert = React.lazy(() => import('./lazy_wrapper/tls_alert')); export const initTlsAlertType: AlertTypeInitializer = ({ core, @@ -29,6 +29,7 @@ export const initTlsAlertType: AlertTypeInitializer = ({ description, validate: () => ({ errors: {} }), defaultActionMessage, + defaultRecoveryMessage, requiresAppContext: false, format: ({ fields }) => ({ reason: fields[ALERT_REASON] || '', diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.test.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.test.ts index 16c49d7c3afcb..068cdfd90b1ae 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.test.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.test.ts @@ -50,7 +50,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}', }, id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', }, @@ -75,7 +75,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}', }, }, ]); @@ -93,7 +93,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}', }, id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9', }, @@ -118,7 +118,7 @@ describe('Alert Actions factory', () => { eventAction: 'trigger', severity: 'error', summary: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}', }, }, ]); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.ts index eabfe42691e8d..31d8c0577780c 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/alert_actions.ts @@ -127,11 +127,11 @@ function getIndexActionParams(selectedMonitor: Ping, recovery = false): IndexAct return { documents: [ { - monitorName: '{{state.monitorName}}', - monitorUrl: '{{{state.monitorUrl}}}', + monitorName: '{{context.monitorName}}', + monitorUrl: '{{{context.monitorUrl}}}', statusMessage: getRecoveryMessage(selectedMonitor), latestErrorMessage: '', - observerLocation: '{{state.observerLocation}}', + observerLocation: '{{context.observerLocation}}', }, ], indexOverride: null, @@ -140,11 +140,11 @@ function getIndexActionParams(selectedMonitor: Ping, recovery = false): IndexAct return { documents: [ { - monitorName: '{{state.monitorName}}', - monitorUrl: '{{{state.monitorUrl}}}', - statusMessage: '{{{state.statusMessage}}}', - latestErrorMessage: '{{{state.latestErrorMessage}}}', - observerLocation: '{{state.observerLocation}}', + monitorName: '{{context.monitorName}}', + monitorUrl: '{{{context.monitorUrl}}}', + statusMessage: '{{{context.statusMessage}}}', + latestErrorMessage: '{{{context.latestErrorMessage}}}', + observerLocation: '{{context.observerLocation}}', }, ], indexOverride: null, diff --git a/x-pack/plugins/synthetics/public/plugin.ts b/x-pack/plugins/synthetics/public/plugin.ts index 412f9167a8845..0daf2fbe74de2 100644 --- a/x-pack/plugins/synthetics/public/plugin.ts +++ b/x-pack/plugins/synthetics/public/plugin.ts @@ -125,7 +125,6 @@ export class UptimePlugin }); registerUptimeRoutesWithNavigation(core, plugins); - registerSyntheticsRoutesWithNavigation(core, plugins); const { observabilityRuleTypeRegistry } = plugins.observability; @@ -196,6 +195,8 @@ export class UptimePlugin const isSyntheticsViewEnabled = core.uiSettings.get(enableNewSyntheticsView); if (isSyntheticsViewEnabled) { + registerSyntheticsRoutesWithNavigation(core, plugins); + // Register the Synthetics UI plugin core.application.register({ id: 'synthetics', diff --git a/x-pack/plugins/synthetics/server/lib/alerts/common.ts b/x-pack/plugins/synthetics/server/lib/alerts/common.ts index 8381adce21d2c..f370b258b482f 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/common.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/common.ts @@ -8,6 +8,7 @@ import { isRight } from 'fp-ts/lib/Either'; import Mustache from 'mustache'; import { IBasePath } from '@kbn/core/server'; +import { RuleExecutorServices } from '@kbn/alerting-plugin/server'; import { UptimeCommonState, UptimeCommonStateType } from '../../../common/runtime_types'; export type UpdateUptimeAlertState = ( @@ -59,9 +60,17 @@ export const updateState: UpdateUptimeAlertState = (state, isTriggeredNow) => { }; export const generateAlertMessage = (messageTemplate: string, fields: Record) => { - return Mustache.render(messageTemplate, { state: { ...fields } }); + return Mustache.render(messageTemplate, { context: { ...fields }, state: { ...fields } }); }; export const getViewInAppUrl = (relativeViewInAppUrl: string, basePath: IBasePath) => basePath.publicBaseUrl ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() : relativeViewInAppUrl; + +export const setRecoveredAlertsContext = (alertFactory: RuleExecutorServices['alertFactory']) => { + const { getRecoveredAlerts } = alertFactory.done(); + for (const alert of getRecoveredAlerts()) { + const state = alert.getState(); + alert.setContext(state); + } +}; diff --git a/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.test.ts b/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.test.ts index eb4509850414b..ad821a509b77b 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.test.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.test.ts @@ -12,7 +12,6 @@ import { import { durationAnomalyAlertFactory } from './duration_anomaly'; import { DURATION_ANOMALY } from '../../../common/constants/alerts'; import { AnomaliesTableRecord, AnomalyRecordDoc } from '@kbn/ml-plugin/common/types/anomalies'; -import { DynamicSettings } from '../../../common/runtime_types'; import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; import { getSeverityType } from '@kbn/ml-plugin/common/util/anomaly_utils'; import { Ping } from '../../../common/runtime_types/ping'; @@ -33,34 +32,6 @@ interface MockAnomalyResult { const monitorId = 'uptime-monitor'; const mockUrl = 'https://elastic.co'; -/** - * This function aims to provide an easy way to give mock props that will - * reduce boilerplate for tests. - * @param dynamic the expiration and aging thresholds received at alert creation time - * @param params the params received at alert creation time - * @param state the state the alert maintains - */ -const mockOptions = ( - dynamicCertSettings?: { - certExpirationThreshold: DynamicSettings['certExpirationThreshold']; - certAgeThreshold: DynamicSettings['certAgeThreshold']; - }, - state = {}, - params = { - timerange: { from: 'now-15m', to: 'now' }, - monitorId, - severity: 'warning', - } -): any => { - const { services } = createRuleTypeMocks(dynamicCertSettings); - - return { - params, - state, - services, - }; -}; - const mockAnomaliesResult: MockAnomalyResult = { anomalies: [ { @@ -94,6 +65,50 @@ const mockPing: Partial = { }, }; +const mockRecoveredAlerts = mockAnomaliesResult.anomalies.map((result) => ({ + firstCheckedAt: 'date', + firstTriggeredAt: undefined, + lastCheckedAt: 'date', + lastResolvedAt: undefined, + isTriggered: false, + anomalyStartTimestamp: 'date', + currentTriggerStarted: undefined, + expectedResponseTime: `${Math.round(result.typicalSort / 1000)} ms`, + lastTriggeredAt: undefined, + monitor: monitorId, + monitorUrl: mockPing.url?.full, + observerLocation: result.entityValue, + severity: getSeverityType(result.severity), + severityScore: result.severity, + slowestAnomalyResponse: `${Math.round(result.actualSort / 1000)} ms`, + bucketSpan: result.source.bucket_span, +})); + +/** + * This function aims to provide an easy way to give mock props that will + * reduce boilerplate for tests. + * @param dynamic the expiration and aging thresholds received at alert creation time + * @param params the params received at alert creation time + * @param state the state the alert maintains + */ +const mockOptions = ( + state = {}, + params = { + timerange: { from: 'now-15m', to: 'now' }, + monitorId, + severity: 'warning', + } +): any => { + const { services, setContext } = createRuleTypeMocks(mockRecoveredAlerts); + + return { + params, + state, + services, + setContext, + }; +}; + describe('duration anomaly alert', () => { let toISOStringSpy: jest.SpyInstance; const mockDate = 'date'; @@ -206,7 +221,7 @@ Response times as high as ${slowestResponse} ms have been detected from location )} level) response time detected on uptime-monitor with url ${ mockPing.url?.full } at date. Anomaly severity score is ${anomaly.severity}. - Response times as high as ${slowestResponse} ms have been detected from location ${ +Response times as high as ${slowestResponse} ms have been detected from location ${ anomaly.entityValue }. Expected response time is ${typicalResponse} ms.`; @@ -218,7 +233,17 @@ Response times as high as ${slowestResponse} ms have been detected from location Array [ "xpack.uptime.alerts.actionGroups.durationAnomaly", Object { - "${ALERT_REASON_MSG}": "${reasonMessages[0]}", + "anomalyStartTimestamp": "date", + "bucketSpan": 900, + "expectedResponseTime": "10 ms", + "monitor": "uptime-monitor", + "monitorUrl": "https://elastic.co", + "observerLocation": "harrisburg", + "${ALERT_REASON_MSG}": "Abnormal (minor level) response time detected on uptime-monitor with url https://elastic.co at date. Anomaly severity score is 25. + Response times as high as 200 ms have been detected from location harrisburg. Expected response time is 10 ms.", + "severity": "minor", + "severityScore": 25, + "slowestAnomalyResponse": "200 ms", "${VIEW_IN_APP_URL}": "http://localhost:5601/hfe/app/uptime/monitor/eHBhY2sudXB0aW1lLmFsZXJ0cy5hY3Rpb25Hcm91cHMuZHVyYXRpb25Bbm9tYWx5MA==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z", }, ] @@ -227,11 +252,52 @@ Response times as high as ${slowestResponse} ms have been detected from location Array [ "xpack.uptime.alerts.actionGroups.durationAnomaly", Object { - "${ALERT_REASON_MSG}": "${reasonMessages[1]}", + "anomalyStartTimestamp": "date", + "bucketSpan": 900, + "expectedResponseTime": "20 ms", + "monitor": "uptime-monitor", + "monitorUrl": "https://elastic.co", + "observerLocation": "fairbanks", + "${ALERT_REASON_MSG}": "Abnormal (warning level) response time detected on uptime-monitor with url https://elastic.co at date. Anomaly severity score is 10. + Response times as high as 300 ms have been detected from location fairbanks. Expected response time is 20 ms.", + "severity": "warning", + "severityScore": 10, + "slowestAnomalyResponse": "300 ms", "${VIEW_IN_APP_URL}": "http://localhost:5601/hfe/app/uptime/monitor/eHBhY2sudXB0aW1lLmFsZXJ0cy5hY3Rpb25Hcm91cHMuZHVyYXRpb25Bbm9tYWx5MQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z", }, ] `); }); + + it('sets alert recovery context for recovered alerts', async () => { + toISOStringSpy.mockImplementation(() => mockDate); + const mockResultServiceProviderGetter: jest.Mock<{ + getAnomaliesTableData: jest.Mock; + }> = jest.fn(); + const mockGetAnomliesTableDataGetter: jest.Mock = jest.fn(); + const mockGetLatestMonitorGetter: jest.Mock> = jest.fn(); + + mockGetLatestMonitorGetter.mockReturnValue(mockPing); + mockGetAnomliesTableDataGetter.mockReturnValue(mockAnomaliesResult); + mockResultServiceProviderGetter.mockReturnValue({ + getAnomaliesTableData: mockGetAnomliesTableDataGetter, + }); + const { server, libs, plugins } = bootstrapDependencies( + { getLatestMonitor: mockGetLatestMonitorGetter }, + { + ml: { + resultsServiceProvider: mockResultServiceProviderGetter, + }, + } + ); + const alert = durationAnomalyAlertFactory(server, libs, plugins); + const options = mockOptions(); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(options.setContext).toHaveBeenCalledTimes(2); + mockRecoveredAlerts.forEach((alertState) => { + expect(options.setContext).toHaveBeenCalledWith(alertState); + }); + }); }); }); diff --git a/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.ts index f2ec05b11f5ea..a93d44013708b 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/duration_anomaly.ts @@ -15,7 +15,12 @@ import { import { ActionGroupIdsOf } from '@kbn/alerting-plugin/common'; import { AnomaliesTableRecord } from '@kbn/ml-plugin/common/types/anomalies'; import { getSeverityType } from '@kbn/ml-plugin/common/util/anomaly_utils'; -import { updateState, generateAlertMessage, getViewInAppUrl } from './common'; +import { + updateState, + generateAlertMessage, + getViewInAppUrl, + setRecoveredAlertsContext, +} from './common'; import { CLIENT_ALERT_TYPES, DURATION_ANOMALY } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; import { UptimeCorePluginsSetup } from '../adapters/framework'; @@ -94,14 +99,26 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory }, ], actionVariables: { - context: [ACTION_VARIABLES[ALERT_REASON_MSG], ACTION_VARIABLES[VIEW_IN_APP_URL]], + context: [ + ACTION_VARIABLES[ALERT_REASON_MSG], + ACTION_VARIABLES[VIEW_IN_APP_URL], + ...durationAnomalyTranslations.actionVariables, + ...commonStateTranslations, + ], state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], }, isExportable: true, minimumLicenseRequired: 'platinum', + doesSetRecoveryContext: true, async executor({ params, - services: { alertWithLifecycle, scopedClusterClient, savedObjectsClient, getAlertStartedDate }, + services: { + alertWithLifecycle, + scopedClusterClient, + savedObjectsClient, + getAlertStartedDate, + alertFactory, + }, state, startedAt, }) { @@ -160,10 +177,13 @@ export const durationAnomalyAlertFactory: UptimeAlertTypeFactory alertInstance.scheduleActions(DURATION_ANOMALY.id, { [ALERT_REASON_MSG]: alertReasonMessage, [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), + ...summary, }); }); } + setRecoveredAlertsContext(alertFactory); + return updateState(state, foundAnomalies); }, }); diff --git a/x-pack/plugins/synthetics/server/lib/alerts/status_check.test.ts b/x-pack/plugins/synthetics/server/lib/alerts/status_check.test.ts index 84e7c0d68400c..b9a90ee18038a 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/status_check.test.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/status_check.test.ts @@ -56,6 +56,53 @@ const mockMonitors = [ }, ]; +const mockRecoveredAlerts = [ + { + currentTriggerStarted: '2022-04-25T14:36:31.511Z', + firstCheckedAt: '2022-04-25T14:10:30.785Z', + firstTriggeredAt: '2022-04-25T14:10:30.785Z', + lastCheckedAt: '2022-04-25T14:36:31.511Z', + lastTriggeredAt: '2022-04-25T14:36:31.511Z', + lastResolvedAt: '2022-04-25T14:23:43.007Z', + isTriggered: true, + monitorUrl: 'https://expired.badssl.com/', + monitorId: 'expired-badssl', + monitorName: 'BadSSL Expired', + monitorType: 'http', + latestErrorMessage: + 'Get "https://expired.badssl.com/": x509: certificate has expired or is not yet valid: current time 2022-04-25T10:36:27-04:00 is after 2015-04-12T23:59:59Z', + observerLocation: 'Unnamed-location', + observerHostname: 'Dominiques-MacBook-Pro-2.local', + reason: + 'BadSSL Expired from Unnamed-location failed 2 times in the last 3 mins. Alert when > 1.', + statusMessage: 'failed 2 times in the last 3 mins. Alert when > 1.', + start: '2022-04-25T14:36:31.621Z', + duration: 315110000000, + }, + { + currentTriggerStarted: '2022-04-25T14:36:31.511Z', + firstCheckedAt: '2022-04-25T14:10:30.785Z', + firstTriggeredAt: '2022-04-25T14:10:30.785Z', + lastCheckedAt: '2022-04-25T14:36:31.511Z', + lastTriggeredAt: '2022-04-25T14:36:31.511Z', + lastResolvedAt: '2022-04-25T14:23:43.007Z', + isTriggered: true, + monitorUrl: 'https://invalid.badssl.com/', + monitorId: 'expired-badssl', + monitorName: 'BadSSL Expired', + monitorType: 'http', + latestErrorMessage: + 'Get "https://invalid.badssl.com/": x509: certificate has expired or is not yet valid: current time 2022-04-25T10:36:27-04:00 is after 2015-04-12T23:59:59Z', + observerLocation: 'Unnamed-location', + observerHostname: 'Dominiques-MacBook-Pro-2.local', + reason: + 'BadSSL Expired from Unnamed-location failed 2 times in the last 3 mins. Alert when > 1.', + statusMessage: 'failed 2 times in the last 3 mins. Alert when > 1.', + start: '2022-04-25T14:36:31.621Z', + duration: 315110000000, + }, +]; + const mockCommonAlertDocumentFields = (monitorInfo: GetMonitorStatusResult['monitorInfo']) => ({ 'agent.name': monitorInfo.agent?.name, 'error.message': monitorInfo.error?.message, @@ -121,13 +168,14 @@ const mockOptions = ( }, } ): any => { - const { services } = createRuleTypeMocks(); + const { services, setContext } = createRuleTypeMocks(mockRecoveredAlerts); return { params, state, services, rule, + setContext, }; }; @@ -142,6 +190,7 @@ describe('status check alert', () => { afterEach(() => { jest.clearAllMocks(); }); + describe('executor', () => { it('does not trigger when there are no monitors down', async () => { expect.assertions(5); @@ -242,7 +291,15 @@ describe('status check alert', () => { Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": "error message 1", + "monitorId": "first", + "monitorName": "First", + "monitorType": "myType", + "monitorUrl": "localhost:8080", + "observerHostname": undefined, + "observerLocation": "harrisburg", "reason": "First from harrisburg failed 234 times in the last 15 mins. Alert when > 5.", + "statusMessage": "failed 234 times in the last 15 mins. Alert when > 5.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zmlyc3Q=?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ] @@ -313,7 +370,15 @@ describe('status check alert', () => { Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": "error message 1", + "monitorId": "first", + "monitorName": "First", + "monitorType": "myType", + "monitorUrl": "localhost:8080", + "observerHostname": undefined, + "observerLocation": "harrisburg", "reason": "First from harrisburg failed 234 times in the last 15m. Alert when > 5.", + "statusMessage": "failed 234 times in the last 15m. Alert when > 5.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zmlyc3Q=?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ] @@ -785,28 +850,60 @@ describe('status check alert', () => { Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": undefined, + "monitorId": "foo", + "monitorName": "Foo", + "monitorType": "myType", + "monitorUrl": "https://foo.com", + "observerHostname": undefined, + "observerLocation": "harrisburg", "reason": "Foo from harrisburg 35 days availability is 99.28%. Alert when < 99.34%.", + "statusMessage": "35 days availability is 99.28%. Alert when < 99.34%.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zm9v?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22harrisburg%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": undefined, + "monitorId": "foo", + "monitorName": "Foo", + "monitorType": "myType", + "monitorUrl": "https://foo.com", + "observerHostname": undefined, + "observerLocation": "fairbanks", "reason": "Foo from fairbanks 35 days availability is 98.03%. Alert when < 99.34%.", + "statusMessage": "35 days availability is 98.03%. Alert when < 99.34%.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/Zm9v?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": undefined, + "monitorId": "unreliable", + "monitorName": "Unreliable", + "monitorType": "myType", + "monitorUrl": "https://unreliable.co", + "observerHostname": undefined, + "observerLocation": "fairbanks", "reason": "Unreliable from fairbanks 35 days availability is 90.92%. Alert when < 99.34%.", + "statusMessage": "35 days availability is 90.92%. Alert when < 99.34%.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/dW5yZWxpYWJsZQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { + "latestErrorMessage": undefined, + "monitorId": "no-name", + "monitorName": "no-name", + "monitorType": "myType", + "monitorUrl": "https://no-name.co", + "observerHostname": undefined, + "observerLocation": "fairbanks", "reason": "no-name from fairbanks 35 days availability is 90.92%. Alert when < 99.34%.", + "statusMessage": "35 days availability is 90.92%. Alert when < 99.34%.", "viewInAppUrl": "http://localhost:5601/hfe/app/uptime/monitor/bm8tbmFtZQ==?dateRangeEnd=now&dateRangeStart=2022-03-17T13%3A13%3A33.755Z&filters=%5B%5B%22observer.geo.name%22%2C%5B%22fairbanks%22%5D%5D%5D", }, ], @@ -909,6 +1006,26 @@ describe('status check alert', () => { }) ); }); + + it('sets alert recovery context for recovered alerts', async () => { + toISOStringSpy.mockImplementation(() => 'foo date string'); + const mockGetter: jest.Mock = jest.fn(); + + mockGetter.mockReturnValue(mockMonitors); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); + const options = mockOptions(); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(options.setContext).toHaveBeenCalledTimes(2); + mockRecoveredAlerts.forEach((alertState) => { + expect(options.setContext).toHaveBeenCalledWith(alertState); + }); + }); + }); + + describe('alert recovery', () => { + it('sets context for alert recovery', () => {}); }); describe('alert factory', () => { diff --git a/x-pack/plugins/synthetics/server/lib/alerts/status_check.ts b/x-pack/plugins/synthetics/server/lib/alerts/status_check.ts index d305dedea3e10..243749f686106 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/status_check.ts @@ -21,7 +21,7 @@ import { GetMonitorAvailabilityParams, } from '../../../common/runtime_types'; import { CLIENT_ALERT_TYPES, MONITOR_STATUS } from '../../../common/constants/alerts'; -import { updateState, getViewInAppUrl } from './common'; +import { updateState, getViewInAppUrl, setRecoveredAlertsContext } from './common'; import { commonMonitorStateI18, commonStateTranslations, @@ -47,6 +47,7 @@ import { import { getMonitorRouteFromMonitorId } from '../../../common/utils/get_monitor_url'; export type ActionGroupIds = ActionGroupIdsOf; + /** * Returns the appropriate range for filtering the documents by `@timestamp`. * @@ -75,22 +76,6 @@ export function getTimestampRange({ }; } -const getMonIdByLoc = (monitorId: string, location: string) => { - return monitorId + '-' + location; -}; - -const uniqueDownMonitorIds = (items: GetMonitorStatusResult[]): Set => - items.reduce( - (acc, { monitorId, location }) => acc.add(getMonIdByLoc(monitorId, location)), - new Set() - ); - -const uniqueAvailMonitorIds = (items: GetMonitorAvailabilityResult[]): Set => - items.reduce( - (acc, { monitorId, location }) => acc.add(getMonIdByLoc(monitorId, location)), - new Set() - ); - export const getUniqueIdsByLoc = ( downMonitorsByLocation: GetMonitorStatusResult[], availabilityResults: GetMonitorAvailabilityResult[] @@ -161,7 +146,7 @@ export const getMonitorSummary = (monitorInfo: Ping, statusMessage: string) => { return { ...summary, - reason: `${monitorName} from ${observerLocation} ${statusMessage}`, + [ALERT_REASON_MSG]: `${monitorName} from ${observerLocation} ${statusMessage}`, }; }; @@ -222,6 +207,22 @@ export const getInstanceId = (monitorInfo: Ping, monIdByLoc: string) => { return `${urlText}_${monIdByLoc}`; }; +const getMonIdByLoc = (monitorId: string, location: string) => { + return monitorId + '-' + location; +}; + +const uniqueDownMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce( + (acc, { monitorId, location }) => acc.add(getMonIdByLoc(monitorId, location)), + new Set() + ); + +const uniqueAvailMonitorIds = (items: GetMonitorAvailabilityResult[]): Set => + items.reduce( + (acc, { monitorId, location }) => acc.add(getMonIdByLoc(monitorId, location)), + new Set() + ); + export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({ id: CLIENT_ALERT_TYPES.MONITOR_STATUS, producer: 'uptime', @@ -281,15 +282,23 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( ACTION_VARIABLES[MONITOR_WITH_GEO], ACTION_VARIABLES[ALERT_REASON_MSG], ACTION_VARIABLES[VIEW_IN_APP_URL], + ...commonMonitorStateI18, ], state: [...commonMonitorStateI18, ...commonStateTranslations], }, isExportable: true, minimumLicenseRequired: 'basic', + doesSetRecoveryContext: true, async executor({ params: rawParams, state, - services: { savedObjectsClient, scopedClusterClient, alertWithLifecycle, getAlertStartedDate }, + services: { + savedObjectsClient, + scopedClusterClient, + alertWithLifecycle, + getAlertStartedDate, + alertFactory, + }, rule: { schedule: { interval }, }, @@ -314,14 +323,12 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( }); const filterString = await formatFilterString(uptimeEsClient, filters, search, libs); - const timespanInterval = `${String(timerangeCount)}${timerangeUnit}`; // Range filter for `monitor.timespan`, the range of time the ping is valid const timespanRange = oldVersionTimeRange || { from: `now-${timespanInterval}`, to: 'now', }; - // Range filter for `@timestamp`, the time the document was indexed const timestampRange = getTimestampRange({ ruleScheduleLookback: `now-${interval}`, @@ -364,10 +371,14 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( fields: getMonitorAlertDocument(monitorSummary), }); - alert.replaceState({ - ...state, + const context = { ...monitorSummary, statusMessage, + }; + + alert.replaceState({ + ...state, + ...context, ...updateState(state, true), }); @@ -381,10 +392,11 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( }); alert.scheduleActions(MONITOR_STATUS.id, { - [ALERT_REASON_MSG]: monitorSummary.reason, [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), + ...context, }); } + setRecoveredAlertsContext(alertFactory); return updateState(state, downMonitorsByLocation.length > 0); } @@ -436,11 +448,16 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( fields: getMonitorAlertDocument(monitorSummary), }); - alert.replaceState({ - ...updateState(state, true), + const context = { ...monitorSummary, statusMessage, + }; + + alert.replaceState({ + ...updateState(state, true), + ...context, }); + const relativeViewInAppUrl = getMonitorRouteFromMonitorId({ monitorId: monitorSummary.monitorId, dateRangeEnd: 'now', @@ -451,10 +468,11 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = ( }); alert.scheduleActions(MONITOR_STATUS.id, { - [ALERT_REASON_MSG]: monitorSummary.reason, [VIEW_IN_APP_URL]: getViewInAppUrl(relativeViewInAppUrl, basePath), + ...context, }); }); + setRecoveredAlertsContext(alertFactory); return updateState(state, downMonitorsByLocation.length > 0); }, }); diff --git a/x-pack/plugins/synthetics/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/synthetics/server/lib/alerts/test_utils/index.ts index af248af730eee..456b0675eee87 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/test_utils/index.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/test_utils/index.ts @@ -13,8 +13,6 @@ import { UMServerLibs } from '../../lib'; import { UptimeCorePluginsSetup, UptimeServerSetup } from '../../adapters'; import type { UptimeRouter } from '../../../types'; import { getUptimeESMockClient } from '../../requests/helper'; -import { DynamicSettings } from '../../../../common/runtime_types'; -import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; /** * The alert takes some dependencies as parameters; these are things like @@ -41,15 +39,7 @@ export const bootstrapDependencies = (customRequests?: any, customPlugins: any = return { server, libs, plugins }; }; -export const createRuleTypeMocks = ( - dynamicCertSettings: { - certAgeThreshold: DynamicSettings['certAgeThreshold']; - certExpirationThreshold: DynamicSettings['certExpirationThreshold']; - } = { - certAgeThreshold: DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold, - certExpirationThreshold: DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold, - } -) => { +export const createRuleTypeMocks = (recoveredAlerts: Array> = []) => { const loggerMock = { debug: jest.fn(), warn: jest.fn(), @@ -58,10 +48,17 @@ export const createRuleTypeMocks = ( const scheduleActions = jest.fn(); const replaceState = jest.fn(); + const setContext = jest.fn(); const services = { ...getUptimeESMockClient(), ...alertsMock.createRuleExecutorServices(), + alertFactory: { + ...alertsMock.createRuleExecutorServices().alertFactory, + done: () => ({ + getRecoveredAlerts: () => createRecoveredAlerts(recoveredAlerts, setContext), + }), + }, alertWithLifecycle: jest.fn().mockReturnValue({ scheduleActions, replaceState }), getAlertStartedDate: jest.fn().mockReturnValue('2022-03-17T13:13:33.755Z'), logger: loggerMock, @@ -77,5 +74,14 @@ export const createRuleTypeMocks = ( services, scheduleActions, replaceState, + setContext, }; }; + +const createRecoveredAlerts = (alerts: Array>, setContext: jest.Mock) => { + return alerts.map((alert) => ({ + getState: () => alert, + setContext, + context: {}, + })); +}; diff --git a/x-pack/plugins/synthetics/server/lib/alerts/tls.test.ts b/x-pack/plugins/synthetics/server/lib/alerts/tls.test.ts index 31a5e98bf9f02..88f8b964eb590 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/tls.test.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/tls.test.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import { tlsAlertFactory, getCertSummary } from './tls'; import { TLS } from '../../../common/constants/alerts'; -import { CertResult, DynamicSettings } from '../../../common/runtime_types'; +import { CertResult } from '../../../common/runtime_types'; import { createRuleTypeMocks, bootstrapDependencies } from './test_utils'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; @@ -19,24 +19,6 @@ import { savedObjectsAdapter, UMSavedObjectsAdapter } from '../saved_objects/sav * @param params the params received at alert creation time * @param state the state the alert maintains */ -const mockOptions = ( - dynamicCertSettings?: { - certExpirationThreshold: DynamicSettings['certExpirationThreshold']; - certAgeThreshold: DynamicSettings['certAgeThreshold']; - }, - state = {} -): any => { - const { services } = createRuleTypeMocks(dynamicCertSettings); - const params = { - timerange: { from: 'now-15m', to: 'now' }, - }; - - return { - params, - state, - services, - }; -}; const mockCertResult: CertResult = { certs: [ @@ -76,6 +58,35 @@ const mockCertResult: CertResult = { total: 4, }; +const mockRecoveredAlerts = [ + { + commonName: mockCertResult.certs[0].common_name ?? '', + issuer: mockCertResult.certs[0].issuer ?? '', + summary: 'sample summary', + status: 'expired', + }, + { + commonName: mockCertResult.certs[1].common_name ?? '', + issuer: mockCertResult.certs[1].issuer ?? '', + summary: 'sample summary 2', + status: 'aging', + }, +]; + +const mockOptions = (state = {}): any => { + const { services, setContext } = createRuleTypeMocks(mockRecoveredAlerts); + const params = { + timerange: { from: 'now-15m', to: 'now' }, + }; + + return { + params, + state, + services, + setContext, + }; +}; + describe('tls alert', () => { let toISOStringSpy: jest.SpyInstance; let savedObjectsAdapterSpy: jest.SpyInstance< @@ -131,16 +142,18 @@ describe('tls alert', () => { const [{ value: alertInstanceMock }] = alertWithLifecycle.mock.results; expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(4); mockCertResult.certs.forEach((cert) => { - expect(alertInstanceMock.replaceState).toBeCalledWith( - expect.objectContaining({ - commonName: cert.common_name, - issuer: cert.issuer, - status: 'expired', - }) + const context = { + commonName: cert.common_name, + issuer: cert.issuer, + status: 'expired', + }; + expect(alertInstanceMock.replaceState).toBeCalledWith(expect.objectContaining(context)); + expect(alertInstanceMock.scheduleActions).toBeCalledWith( + TLS.id, + expect.objectContaining(context) ); }); expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(4); - expect(alertInstanceMock.scheduleActions).toBeCalledWith(TLS.id); }); it('handles dynamic settings for aging or expiration threshold', async () => { @@ -167,6 +180,22 @@ describe('tls alert', () => { }) ); }); + + it('sets alert recovery context for recovered alerts', async () => { + toISOStringSpy.mockImplementation(() => 'foo date string'); + const mockGetter: jest.Mock = jest.fn(); + + mockGetter.mockReturnValue(mockCertResult); + const { server, libs, plugins } = bootstrapDependencies({ getCerts: mockGetter }); + const alert = tlsAlertFactory(server, libs, plugins); + const options = mockOptions(); + // @ts-ignore the executor can return `void`, but ours never does + const state: Record = await alert.executor(options); + expect(options.setContext).toHaveBeenCalledTimes(2); + mockRecoveredAlerts.forEach((alertState) => { + expect(options.setContext).toHaveBeenCalledWith(alertState); + }); + }); }); describe('getCertSummary', () => { diff --git a/x-pack/plugins/synthetics/server/lib/alerts/tls.ts b/x-pack/plugins/synthetics/server/lib/alerts/tls.ts index 0a6fb24c88156..127171eab0f4d 100644 --- a/x-pack/plugins/synthetics/server/lib/alerts/tls.ts +++ b/x-pack/plugins/synthetics/server/lib/alerts/tls.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { ALERT_REASON } from '@kbn/rule-data-utils'; import { ActionGroupIdsOf } from '@kbn/alerting-plugin/common'; import { UptimeAlertTypeFactory } from './types'; -import { updateState, generateAlertMessage } from './common'; +import { updateState, generateAlertMessage, setRecoveredAlertsContext } from './common'; import { CLIENT_ALERT_TYPES, TLS } from '../../../common/constants/alerts'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants'; import { Cert, CertResult } from '../../../common/runtime_types'; @@ -108,13 +108,14 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, }, ], actionVariables: { - context: [], + context: [...tlsTranslations.actionVariables, ...commonStateTranslations], state: [...tlsTranslations.actionVariables, ...commonStateTranslations], }, isExportable: true, minimumLicenseRequired: 'basic', + doesSetRecoveryContext: true, async executor({ - services: { alertWithLifecycle, savedObjectsClient, scopedClusterClient }, + services: { alertWithLifecycle, savedObjectsClient, scopedClusterClient, alertFactory }, state, }) { const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); @@ -173,10 +174,12 @@ export const tlsAlertFactory: UptimeAlertTypeFactory = (_server, ...updateState(state, foundCerts), ...summary, }); - alertInstance.scheduleActions(TLS.id); + alertInstance.scheduleActions(TLS.id, { ...summary }); }); } + setRecoveredAlertsContext(alertFactory); + return updateState(state, foundCerts); }, }); diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index 73d30b022c626..5cce445e7bb4c 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -1517,6 +1517,54 @@ describe('TaskManagerRunner', () => { `Skipping reschedule for task bar \"${id}\" due to the task expiring` ); }); + + test('Prints debug logs on task start/end', async () => { + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + return { state: {} }; + }, + }), + }, + }, + }); + await runner.run(); + + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Running task bar "foo"', { + tags: ['task:start', 'foo', 'bar'], + }); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'Task bar "foo" ended', { + tags: ['task:end', 'foo', 'bar'], + }); + }); + + test('Prints debug logs on task start/end even if it throws error', async () => { + const { runner, logger } = await readyToRunStageSetup({ + definitions: { + bar: { + title: 'Bar!', + createTaskRunner: () => ({ + async run() { + throw new Error(); + }, + }), + }, + }, + }); + await runner.run(); + + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith(1, 'Running task bar "foo"', { + tags: ['task:start', 'foo', 'bar'], + }); + expect(logger.debug).toHaveBeenNthCalledWith(2, 'Task bar "foo" ended', { + tags: ['task:end', 'foo', 'bar'], + }); + }); }); interface TestOpts { diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index b6199f06300f1..d305a49bef55e 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -283,7 +283,7 @@ export class TaskManagerRunner implements TaskRunner { }` ); } - this.logger.debug(`Running task ${this}`); + this.logger.debug(`Running task ${this}`, { tags: ['task:start', this.id, this.taskType] }); const apmTrans = apm.startTransaction(this.taskType, TASK_MANAGER_RUN_TRANSACTION_TYPE, { childOf: this.instance.task.traceparent, @@ -324,6 +324,8 @@ export class TaskManagerRunner implements TaskRunner { ); if (apmTrans) apmTrans.end('failure'); return processedResult; + } finally { + this.logger.debug(`Task ${this} ended`, { tags: ['task:end', this.id, this.taskType] }); } } diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx index 151ed99c3621c..77d5a754d6a97 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, RenderResult } from '@testing-library/react'; import { mockBrowserFields, TestProviders } from '../../../../mock'; import { tGridActions } from '../../../../store/t_grid'; import { defaultColumnHeaderType } from '../../body/column_headers/default_headers'; @@ -155,50 +155,117 @@ describe('FieldTable', () => { expect(checkbox).toHaveAttribute('checked'); }); - it('should dispatch remove column action on field unchecked', () => { - const result = render( - - - - ); + describe('selection', () => { + it('should dispatch remove column action on field unchecked', () => { + const result = render( + + + + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) - ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) + ); + }); + + it('should dispatch upsert column action on field checked', () => { + const result = render( + + + + ); + + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.upsertColumn({ + id: timelineId, + column: { + columnHeaderType: defaultColumnHeaderType, + id: timestampFieldId, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + index: 1, + }) + ); + }); }); - it('should dispatch upsert column action on field checked', () => { - const result = render( - + describe('pagination', () => { + const isAtFirstPage = (result: RenderResult) => + result.getByTestId('pagination-button-0').classList.contains('euiPaginationButton-isActive'); + + const changePage = (result: RenderResult) => { + result.getByTestId('pagination-button-1').click(); + }; + + const defaultPaginationProps: FieldTableProps = { + ...defaultProps, + filteredBrowserFields: mockBrowserFields, + }; + + it('should paginate on page clicked', () => { + const result = render( + + + + ); + + expect(isAtFirstPage(result)).toBeTruthy(); + + changePage(result); + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should not reset on field checked', () => { + const result = render( + + + + ); + + changePage(result); + + result.getAllByRole('checkbox').at(0)?.click(); + expect(mockDispatch).toHaveBeenCalled(); // assert some field has been selected + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should reset on filter change', () => { + const result = render( , + { wrapper: TestProviders } + ); + + changePage(result); + expect(isAtFirstPage(result)).toBeFalsy(); + + result.rerender( + - - ); + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.upsertColumn({ - id: timelineId, - column: { - columnHeaderType: defaultColumnHeaderType, - id: timestampFieldId, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - }, - index: 1, - }) - ); + expect(isAtFirstPage(result)).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx index 684b09d0395ab..4f62cdd246871 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiInMemoryTable, Pagination, Direction } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items'; @@ -16,6 +16,11 @@ import { tGridActions } from '../../../../store/t_grid'; import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser'; import { FieldTableHeader } from './field_table_header'; +const DEFAULT_SORTING: { field: string; direction: Direction } = { + field: '', + direction: 'asc', +} as const; + export interface FieldTableProps { timelineId: string; columnHeaders: ColumnHeaderOptions[]; @@ -69,6 +74,12 @@ const FieldTableComponent: React.FC = ({ timelineId, onHide, }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(DEFAULT_SORTING.field); + const [sortDirection, setSortDirection] = useState(DEFAULT_SORTING.direction); + const dispatch = useDispatch(); const fieldItems = useMemo( @@ -103,6 +114,51 @@ const FieldTableComponent: React.FC = ({ [columnHeaders, dispatch, timelineId] ); + /** + * Pagination controls + */ + const pagination: Pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: fieldItems.length, + pageSizeOptions: [10, 25, 50], + }), + [fieldItems.length, pageIndex, pageSize] + ); + + useEffect(() => { + // Resets the pagination when some filter has changed, consequently, the number of fields is different + setPageIndex(0); + }, [fieldItems.length]); + + /** + * Sorting controls + */ + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const onTableChange = useCallback(({ page, sort = DEFAULT_SORTING }) => { + const { index, size } = page; + const { field, direction } = sort; + + setPageIndex(index); + setPageSize(size); + + setSortField(field); + setSortDirection(direction); + }, []); + + /** + * Process columns + */ const columns = useMemo( () => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns, onHide }), [onToggleColumn, searchInput, getFieldTableColumns, onHide] @@ -124,9 +180,10 @@ const FieldTableComponent: React.FC = ({ items={fieldItems} itemId="name" columns={columns} - pagination={true} - sorting={true} + pagination={pagination} + sorting={sorting} hasActions={hasActions} + onChange={onTableChange} compressed /> diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index e0d549b634a21..6045d50ea26b9 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -5,6 +5,7 @@ "ui": true, "requiredPlugins": [ "data", + "dataViews", "home", "licensing", "management", diff --git a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx index 42708f2a3f2e2..f90faf53e87b5 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx +++ b/x-pack/plugins/transform/public/app/hooks/use_delete_transform.tsx @@ -23,7 +23,7 @@ import { indexService } from '../services/es_index_service'; export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const { http, - savedObjects, + data: { dataViews: dataViewsContract }, ml: { extractErrorMessage }, application: { capabilities }, } = useAppDependencies(); @@ -46,9 +46,8 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { const checkDataViewExists = useCallback( async (indexName: string) => { try { - if (await indexService.dataViewExists(savedObjects.client, indexName)) { - setDataViewExists(true); - } + const dvExists = await indexService.dataViewExists(dataViewsContract, indexName); + setDataViewExists(dvExists); } catch (e) { const error = extractErrorMessage(e); @@ -63,7 +62,7 @@ export const useDeleteIndexAndTargetIndex = (items: TransformListRow[]) => { ); } }, - [savedObjects.client, toastNotifications, extractErrorMessage] + [dataViewsContract, toastNotifications, extractErrorMessage] ); const checkUserIndexPermission = useCallback(async () => { diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts index 07c0a9ae9bcbe..60b2a080bba29 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/common.ts @@ -6,9 +6,9 @@ */ import { buildEsQuery } from '@kbn/es-query'; -import { SavedObjectsClientContract, SimpleSavedObject, IUiSettingsClient } from '@kbn/core/public'; +import type { IUiSettingsClient } from '@kbn/core/public'; import { getEsQueryConfig } from '@kbn/data-plugin/public'; -import { DataView, DataViewAttributes, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; import { matchAllQuery } from '../../common'; @@ -16,58 +16,21 @@ import { isDataView } from '../../../../common/types/data_view'; export type SavedSearchQuery = object; -type DataViewId = string; - -let dataViewCache: Array>> = []; -let fullDataViews; -let currentDataView = null; +let dataViewCache: DataView[] = []; export let refreshDataViews: () => Promise; -export function loadDataViews( - savedObjectsClient: SavedObjectsClientContract, - dataViews: DataViewsContract -) { - fullDataViews = dataViews; - return savedObjectsClient - .find({ - type: 'index-pattern', - fields: ['id', 'title', 'type', 'fields'], - perPage: 10000, - }) - .then((response) => { - dataViewCache = response.savedObjects; - - if (refreshDataViews === null) { - refreshDataViews = () => { - return new Promise((resolve, reject) => { - loadDataViews(savedObjectsClient, dataViews) - .then((resp) => { - resolve(resp); - }) - .catch((error) => { - reject(error); - }); - }); - }; - } - - return dataViewCache; - }); +export async function loadDataViews(dataViewsContract: DataViewsContract) { + dataViewCache = await dataViewsContract.find('*', 10000); + return dataViewCache; } export function getDataViewIdByTitle(dataViewTitle: string): string | undefined { - return dataViewCache.find((d) => d?.attributes?.title === dataViewTitle)?.id; + return dataViewCache.find(({ title }) => title === dataViewTitle)?.id; } type CombinedQuery = Record<'bool', any> | object; -export function loadCurrentDataView(dataViews: DataViewsContract, dataViewId: DataViewId) { - fullDataViews = dataViews; - currentDataView = fullDataViews.get(dataViewId); - return currentDataView; -} - export interface SearchItems { dataView: DataView; savedSearch: any; diff --git a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts index 76fdc77c523e4..cd24d092f754c 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_search_items/use_search_items.ts @@ -15,33 +15,24 @@ import { getSavedSearch, getSavedSearchUrlConflictMessage } from '../../../share import { useAppDependencies } from '../../app_dependencies'; -import { - createSearchItems, - getDataViewIdByTitle, - loadCurrentDataView, - loadDataViews, - SearchItems, -} from './common'; +import { createSearchItems, getDataViewIdByTitle, loadDataViews, SearchItems } from './common'; export const useSearchItems = (defaultSavedObjectId: string | undefined) => { const [savedObjectId, setSavedObjectId] = useState(defaultSavedObjectId); const [error, setError] = useState(); const appDeps = useAppDependencies(); - const dataViews = appDeps.data.dataViews; + const dataViewsContract = appDeps.data.dataViews; const uiSettings = appDeps.uiSettings; - const savedObjectsClient = appDeps.savedObjects.client; const [searchItems, setSearchItems] = useState(undefined); async function fetchSavedObject(id: string) { - await loadDataViews(savedObjectsClient, dataViews); - let fetchedDataView; let fetchedSavedSearch; try { - fetchedDataView = await loadCurrentDataView(dataViews, id); + fetchedDataView = await dataViewsContract.get(id); } catch (e) { // Just let fetchedDataView stay undefined in case it doesn't exist. } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx index f6c700aef67cc..61fd9afdbee14 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/use_clone_action.tsx @@ -21,8 +21,7 @@ export type CloneAction = ReturnType; export const useCloneAction = (forceDisable: boolean, transformNodes: number) => { const history = useHistory(); const appDeps = useAppDependencies(); - const savedObjectsClient = appDeps.savedObjects.client; - const dataViews = appDeps.data.dataViews; + const dataViewsContract = appDeps.data.dataViews; const toastNotifications = useToastNotifications(); const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); @@ -32,7 +31,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => const clickHandler = useCallback( async (item: TransformListRow) => { try { - await loadDataViews(savedObjectsClient, dataViews); + await loadDataViews(dataViewsContract); const dataViewTitle = Array.isArray(item.config.source.index) ? item.config.source.index.join(',') : item.config.source.index; @@ -57,14 +56,7 @@ export const useCloneAction = (forceDisable: boolean, transformNodes: number) => }); } }, - [ - history, - savedObjectsClient, - dataViews, - toastNotifications, - loadDataViews, - getDataViewIdByTitle, - ] + [history, dataViewsContract, toastNotifications, loadDataViews, getDataViewIdByTitle] ); const action: TransformListAction = useMemo( diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx index 9194d9f63045e..aeb5c3e2d09a3 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_discover/use_action_discover.tsx @@ -25,11 +25,12 @@ const getDataViewTitleFromTargetIndex = (item: TransformListRow) => export type DiscoverAction = ReturnType; export const useDiscoverAction = (forceDisable: boolean) => { - const appDeps = useAppDependencies(); - const { share } = appDeps; - const savedObjectsClient = appDeps.savedObjects.client; - const dataViews = appDeps.data.dataViews; - const isDiscoverAvailable = !!appDeps.application.capabilities.discover?.show; + const { + share, + data: { dataViews: dataViewsContract }, + application: { capabilities }, + } = useAppDependencies(); + const isDiscoverAvailable = !!capabilities.discover?.show; const { getDataViewIdByTitle, loadDataViews } = useSearchItems(undefined); @@ -37,12 +38,12 @@ export const useDiscoverAction = (forceDisable: boolean) => { useEffect(() => { async function checkDataViewAvailability() { - await loadDataViews(savedObjectsClient, dataViews); + await loadDataViews(dataViewsContract); setDataViewsLoaded(true); } checkDataViewAvailability(); - }, [dataViews, loadDataViews, savedObjectsClient]); + }, [loadDataViews, dataViewsContract]); const clickHandler = useCallback( (item: TransformListRow) => { diff --git a/x-pack/plugins/transform/public/app/services/es_index_service.ts b/x-pack/plugins/transform/public/app/services/es_index_service.ts index 5a0f907b78e22..609018c30b226 100644 --- a/x-pack/plugins/transform/public/app/services/es_index_service.ts +++ b/x-pack/plugins/transform/public/app/services/es_index_service.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { HttpSetup, SavedObjectsClientContract } from '@kbn/core/public'; -import { DataView } from '@kbn/data-views-plugin/public'; +import type { HttpSetup } from '@kbn/core/public'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { API_BASE_PATH } from '../../../common/constants'; export class IndexService { @@ -18,16 +18,8 @@ export class IndexService { return privilege.hasAllPrivileges; } - async dataViewExists(savedObjectsClient: SavedObjectsClientContract, indexName: string) { - const response = await savedObjectsClient.find({ - type: 'index-pattern', - perPage: 1, - search: `"${indexName}"`, - searchFields: ['title'], - fields: ['title'], - }); - const ip = response.savedObjects.find((obj) => obj.attributes.title === indexName); - return ip !== undefined; + async dataViewExists(dataViewsContract: DataViewsContract, indexName: string) { + return (await dataViewsContract.find(indexName)).some(({ title }) => title === indexName); } } diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 74cd845dd49da..762dfd2bcaab8 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -9,6 +9,7 @@ import { i18n as kbnI18n } from '@kbn/i18n'; import type { CoreSetup } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { SavedObjectsStart } from '@kbn/saved-objects-plugin/public'; import type { ManagementSetup } from '@kbn/management-plugin/public'; @@ -21,6 +22,7 @@ import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; management: ManagementSetup; home: HomePublicPluginSetup; savedObjects: SavedObjectsStart; diff --git a/x-pack/plugins/transform/readme.md b/x-pack/plugins/transform/readme.md index 51a89f224fb29..8a0ea7eb4f660 100644 --- a/x-pack/plugins/transform/readme.md +++ b/x-pack/plugins/transform/readme.md @@ -106,8 +106,8 @@ and Kibana instance that the tests will be run against. 1. Functional UI tests with `Trial` license (default config): - node scripts/functional_tests_server.js - node scripts/functional_test_runner.js --include-tag transform + node scripts/functional_tests_server.js --config test/functional/apps/transform/config.ts + node scripts/functional_test_runner.js --config test/functional/apps/transform/config.ts Transform functional `Trial` license tests are located in `x-pack/test/functional/apps/transform`. @@ -120,7 +120,7 @@ and Kibana instance that the tests will be run against. 1. API integration tests with `Trial` license: - node scripts/functional_tests_server.js + node scripts/functional_tests_server.js --config test/api_integration/config.ts node scripts/functional_test_runner.js --config test/api_integration/config.ts --include-tag transform Transform API integration `Trial` license tests are located in `x-pack/test/api_integration/apis/transform`. diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts index c803785de2c4b..3eab84ca0b5fc 100644 --- a/x-pack/plugins/transform/server/plugin.ts +++ b/x-pack/plugins/transform/server/plugin.ts @@ -6,11 +6,11 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, Plugin, Logger, PluginInitializerContext } from '@kbn/core/server'; +import { CoreSetup, CoreStart, Plugin, Logger, PluginInitializerContext } from '@kbn/core/server'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { Dependencies } from './types'; +import { PluginSetupDependencies, PluginStartDependencies } from './types'; import { ApiRoutes } from './routes'; import { License } from './services'; import { registerTransformHealthRuleType } from './lib/alerting'; @@ -38,8 +38,8 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { } setup( - { http, getStartServices, elasticsearch }: CoreSetup, - { licensing, features, alerting }: Dependencies + { http, getStartServices, elasticsearch }: CoreSetup, + { licensing, features, alerting }: PluginSetupDependencies ): {} { const router = http.createRouter(); @@ -74,6 +74,7 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { this.apiRoutes.setup({ router, license: this.license, + getStartServices, }); if (alerting) { @@ -83,7 +84,7 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { return {}; } - start() {} + start(core: CoreStart, plugins: PluginStartDependencies) {} stop() {} } diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index f1c5e74056a94..7c4df878456ba 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -13,10 +13,9 @@ import { KibanaResponseFactory, RequestHandler, RequestHandlerContext, - SavedObjectsClientContract, } from '@kbn/core/server'; -import { DataView } from '@kbn/data-views-plugin/common'; +import { DataViewsService } from '@kbn/data-views-plugin/common'; import { TRANSFORM_STATE } from '../../../common/constants'; import { transformIdParamSchema, @@ -74,7 +73,7 @@ enum TRANSFORM_ACTIONS { } export function registerTransformsRoutes(routeDependencies: RouteDependencies) { - const { router, license } = routeDependencies; + const { router, license, getStartServices } = routeDependencies; /** * @apiGroup Transforms * @@ -303,7 +302,16 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { license.guardApiRoute( async (ctx, req, res) => { try { - const body = await deleteTransforms(req.body, ctx, res); + const [{ savedObjects, elasticsearch }, { dataViews }] = await getStartServices(); + const savedObjectsClient = savedObjects.getScopedClient(req); + const esClient = elasticsearch.client.asScoped(req).asCurrentUser; + + const dataViewsService = await dataViews.dataViewsServiceFactory( + savedObjectsClient, + esClient, + req + ); + const body = await deleteTransforms(req.body, ctx, res, dataViewsService); if (body && body.status) { if (body.status === 404) { @@ -456,29 +464,20 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { registerTransformNodesRoutes(routeDependencies); } -async function getDataViewId(indexName: string, savedObjectsClient: SavedObjectsClientContract) { - const response = await savedObjectsClient.find({ - type: 'index-pattern', - perPage: 1, - search: `"${indexName}"`, - searchFields: ['title'], - fields: ['title'], - }); - const ip = response.saved_objects.find((obj) => obj.attributes.title === indexName); - return ip?.id; +async function getDataViewId(indexName: string, dataViewsService: DataViewsService) { + const dv = (await dataViewsService.find(indexName)).find(({ title }) => title === indexName); + return dv?.id; } -async function deleteDestDataViewById( - dataViewId: string, - savedObjectsClient: SavedObjectsClientContract -) { - return await savedObjectsClient.delete('index-pattern', dataViewId); +async function deleteDestDataViewById(dataViewId: string, dataViewsService: DataViewsService) { + return await dataViewsService.delete(dataViewId); } async function deleteTransforms( reqBody: DeleteTransformsRequestSchema, ctx: RequestHandlerContext, - response: KibanaResponseFactory + response: KibanaResponseFactory, + dataViewsService: DataViewsService ) { const { transformsInfo } = reqBody; @@ -491,7 +490,6 @@ async function deleteTransforms( const coreContext = await ctx.core; const esClient = coreContext.elasticsearch.client; - const soClient = coreContext.savedObjects.client; for (const transformInfo of transformsInfo) { let destinationIndex: string | undefined; @@ -548,9 +546,9 @@ async function deleteTransforms( // Delete the data view if there's a data view that matches the name of dest index if (destinationIndex && deleteDestDataView) { try { - const dataViewId = await getDataViewId(destinationIndex, soClient); + const dataViewId = await getDataViewId(destinationIndex, dataViewsService); if (dataViewId) { - await deleteDestDataViewById(dataViewId, soClient); + await deleteDestDataViewById(dataViewId, dataViewsService); destDataViewDeleted.success = true; } } catch (deleteDestDataViewError) { diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts index f9be90a1a20da..26bdf02acb5c0 100644 --- a/x-pack/plugins/transform/server/types.ts +++ b/x-pack/plugins/transform/server/types.ts @@ -5,19 +5,25 @@ * 2.0. */ -import { IRouter } from '@kbn/core/server'; +import { IRouter, CoreSetup } from '@kbn/core/server'; +import { PluginStart as DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { AlertingPlugin } from '@kbn/alerting-plugin/server'; import { License } from './services'; -export interface Dependencies { +export interface PluginSetupDependencies { licensing: LicensingPluginSetup; features: FeaturesPluginSetup; alerting?: AlertingPlugin['setup']; } +export interface PluginStartDependencies { + dataViews: DataViewsServerPluginStart; +} + export interface RouteDependencies { router: IRouter; license: License; + getStartServices: CoreSetup['getStartServices']; } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index f23d9cf64202b..513f83e23f177 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -365,7 +365,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle le taux de compteur résultant sera stocké", "xpack.lens.functions.counterRate.help": "Calcule le taux de compteur d'une colonne dans un tableau de données", "xpack.lens.functions.lastValue.missingSortField": "Cette vue de données ne contient aucun champ de date.", - "xpack.lens.functions.mergeTables.help": "Aide pour fusionner n'importe quel nombre de tableaux Kibana en un tableau unique et l'exposer via un adaptateur d'inspecteur", "xpack.lens.functions.renameColumns.help": "Aide pour renommer les colonnes d'un tableau de données", "xpack.lens.functions.renameColumns.idMap.help": "Un objet encodé JSON dans lequel les clés sont les anciens ID de colonne et les valeurs sont les nouveaux ID correspondants. Tous les autres ID de colonne sont conservés.", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "L'ID de colonne de date {columnId} n'existe pas.", @@ -7459,8 +7458,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "Type", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "Ajouter une règle", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "Rattachement automatique", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "BÊTA", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "Le rattachement automatique pour Java n'est pas disponible. Nous vous remercions de bien vouloir nous aider en nous signalant tout bug.", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "Règles de découverte", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "Ajouter", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "Choisir parmi les paramètres autorisés", @@ -9716,7 +9713,6 @@ "xpack.cases.recentCases.viewAllCasesLink": "Afficher tous les cas", "xpack.cases.settings.syncAlertsSwitchLabelOff": "Arrêt", "xpack.cases.settings.syncAlertsSwitchLabelOn": "Marche", - "xpack.cases.status.all": "Tout", "xpack.cases.status.closed": "Fermé", "xpack.cases.status.iconAria": "Modifier le statut", "xpack.cases.status.inProgress": "En cours", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9b42d92c275e9..df42895ac2cbc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -367,7 +367,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "結果のカウンターレートを格納する列の名前", "xpack.lens.functions.counterRate.help": "データテーブルの列のカウンターレートを計算します", "xpack.lens.functions.lastValue.missingSortField": "このデータビューには日付フィールドが含まれていません", - "xpack.lens.functions.mergeTables.help": "任意の数の Kibana 表を 1 つの表に結合し、インスペクターアダプター経由で公開するヘルパー", "xpack.lens.functions.renameColumns.help": "データベースの列の名前の変更をアシストします", "xpack.lens.functions.renameColumns.idMap.help": "キーが古い列 ID で値が対応する新しい列 ID となるように JSON エンコーディングされたオブジェクトです。他の列 ID はすべてのそのままです。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定した dateColumnId {columnId} は存在しません。", @@ -7558,8 +7557,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "型", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "ルールを追加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "自動接続", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "BETA", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "Javaの自動接続は一般公開されていません。不具合が発生したら報告してください。", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "検出ルール", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "追加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "許可されたパラメーターから選択", @@ -9812,7 +9809,6 @@ "xpack.cases.recentCases.viewAllCasesLink": "すべてのケースを表示", "xpack.cases.settings.syncAlertsSwitchLabelOff": "オフ", "xpack.cases.settings.syncAlertsSwitchLabelOn": "オン", - "xpack.cases.status.all": "すべて", "xpack.cases.status.closed": "終了", "xpack.cases.status.iconAria": "ステータスの変更", "xpack.cases.status.inProgress": "進行中", @@ -19940,8 +19936,6 @@ "xpack.ml.trainedModels.testModelsFlyout.headerLabel": "学習済みモデルのテスト", "xpack.ml.trainedModels.testModelsFlyout.inferenceError": "エラーが発生しました", "xpack.ml.trainedModels.testModelsFlyout.langIdent.inputText": "入力テキスト", - "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.language_title": "言語", - "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.probability_title": "確率", "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.title": "これは{lang}のようになります", "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.titleUnknown": "不明な言語コード:{code}", "xpack.ml.trainedModels.testModelsFlyout.ner.output.probabilityTitle": "確率", @@ -31173,6 +31167,7 @@ "xpack.watcher.watchActions.webhook.portIsRequiredValidationMessage": "Webフックポートが必要です。", "xpack.watcher.watchActions.webhook.usernameIsRequiredIfPasswordValidationMessage": "ユーザー名が必要です。", "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "フィールドを選択してください。", + "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。", "unifiedSearch.search.searchBar.savedQueryDescriptionText": "再度使用するクエリテキストとフィルターを保存します。", "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "タイトルがすでに保存されているクエリに使用されています", "unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "保存", @@ -31264,7 +31259,6 @@ "unifiedSearch.filter.options.invertNegatedFiltersButtonLabel": "含める・除外を反転", "unifiedSearch.filter.options.pinAllFiltersButtonLabel": "すべてピン付け", "unifiedSearch.filter.options.unpinAllFiltersButtonLabel": "すべてのピンを外す", - "xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。", "unifiedSearch.kueryAutocomplete.andOperatorDescription": "{bothArguments} が true であることを条件とする", "unifiedSearch.kueryAutocomplete.andOperatorDescription.bothArgumentsText": "両方の引数", "unifiedSearch.kueryAutocomplete.equalOperatorDescription": "一部の値に{equals}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dd87bbaa23fef..6783295921119 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -372,7 +372,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "要存储结果计数率的列名称", "xpack.lens.functions.counterRate.help": "在数据表中计算列的计数率", "xpack.lens.functions.lastValue.missingSortField": "此数据视图不包含任何日期字段", - "xpack.lens.functions.mergeTables.help": "将任意数目的 kibana 表合并到单个表中并通过检查器适配器将其开放的助手", "xpack.lens.functions.renameColumns.help": "用于重命名数据表列的助手", "xpack.lens.functions.renameColumns.idMap.help": "旧列 ID 为键且相应新列 ID 为值的 JSON 编码对象。所有其他列 ID 都将保留。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定的 dateColumnId {columnId} 不存在。", @@ -7576,8 +7575,6 @@ "xpack.apm.fleetIntegration.apmAgent.editDisacoveryRule.type": "类型", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.addRule": "添加规则", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.autoAttachment": "自动附件", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.label": "公测版", - "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.betaBadge.tooltipContent": "用于 Java 的自动附件不是 GA 版。请通过报告错误来帮助我们。", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.discoveryRules": "发现规则", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.add": "添加", "xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.editRule.helpText": "从允许的参数中选择", @@ -9834,7 +9831,6 @@ "xpack.cases.recentCases.viewAllCasesLink": "查看所有案例", "xpack.cases.settings.syncAlertsSwitchLabelOff": "关闭", "xpack.cases.settings.syncAlertsSwitchLabelOn": "开启", - "xpack.cases.status.all": "全部", "xpack.cases.status.closed": "已关闭", "xpack.cases.status.iconAria": "更改状态", "xpack.cases.status.inProgress": "进行中", @@ -19971,8 +19967,6 @@ "xpack.ml.trainedModels.testModelsFlyout.headerLabel": "测试已训练模型", "xpack.ml.trainedModels.testModelsFlyout.inferenceError": "发生错误", "xpack.ml.trainedModels.testModelsFlyout.langIdent.inputText": "输入文本", - "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.language_title": "语言", - "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.probability_title": "可能性", "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.title": "这像是 {lang}", "xpack.ml.trainedModels.testModelsFlyout.langIdent.output.titleUnknown": "语言代码未知:{code}", "xpack.ml.trainedModels.testModelsFlyout.ner.output.probabilityTitle": "可能性", @@ -31209,6 +31203,7 @@ "xpack.watcher.watchActions.webhook.portIsRequiredValidationMessage": "Webhook 端口必填。", "xpack.watcher.watchActions.webhook.usernameIsRequiredIfPasswordValidationMessage": "“用户名”必填。", "xpack.watcher.watchEdit.thresholdWatchExpression.aggType.fieldIsRequiredValidationMessage": "此字段必填。", + "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。", "unifiedSearch.search.searchBar.savedQueryDescriptionText": "保存想要再次使用的查询文本和筛选。", "unifiedSearch.search.searchBar.savedQueryForm.titleConflictText": "标题与现有已保存查询有冲突", "unifiedSearch.search.searchBar.savedQueryFormSaveButtonText": "保存", @@ -31300,7 +31295,6 @@ "unifiedSearch.filter.options.invertNegatedFiltersButtonLabel": "反向包括", "unifiedSearch.filter.options.pinAllFiltersButtonLabel": "全部固定", "unifiedSearch.filter.options.unpinAllFiltersButtonLabel": "全部取消固定", - "xpack.watcher.watcherDescription": "通过创建、管理和监测警报来检测数据中的更改。", "unifiedSearch.kueryAutocomplete.andOperatorDescription": "需要{bothArguments}为 true", "unifiedSearch.kueryAutocomplete.andOperatorDescription.bothArgumentsText": "两个参数都", "unifiedSearch.kueryAutocomplete.equalOperatorDescription": "{equals}某一值", diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index 4d64f02d2c14b..4928b368a96b4 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -95,6 +95,12 @@ export interface DrilldownDefinition< */ isConfigValid: ActionFactoryDefinition['isConfigValid']; + /** + * Compatibility check during drilldown creation. + * Could be used to filter out a drilldown if it's not compatible with the current context. + */ + isConfigurable?(context: FactoryContext): boolean; + /** * Name of EUI icon to display when showing this drilldown to user. */ diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx index db9951f235dfc..f52ac6e161577 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/containers/action_factory_picker/action_factory_picker.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { ActionFactoryPicker as ActionFactoryPickerUi } from '../../../../components/action_factory_picker'; import { useDrilldownManager } from '../context'; import { ActionFactoryView } from '../action_factory_view'; @@ -14,14 +15,19 @@ export const ActionFactoryPicker: React.FC = ({}) => { const drilldowns = useDrilldownManager(); const factory = drilldowns.useActionFactory(); const context = React.useMemo(() => drilldowns.getActionFactoryContext(), [drilldowns]); + const compatibleFactories = drilldowns.useCompatibleActionFactories(context); if (!!factory) { return ; } + if (!compatibleFactories) { + return ; + } + return ( { drilldowns.setActionFactory(actionFactory); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts index 15997355a2ae2..231057a50ee1f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_manager/state/drilldown_manager_state.ts @@ -6,9 +6,10 @@ */ import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import type { SerializableRecord } from '@kbn/utility-types'; +import { useMemo } from 'react'; import { PublicDrilldownManagerProps, DrilldownManagerDependencies, @@ -255,6 +256,24 @@ export class DrilldownManagerState { return context; } + public getCompatibleActionFactories( + context: BaseActionFactoryContext + ): Observable { + const compatibleActionFactories$ = new BehaviorSubject(undefined); + Promise.allSettled( + this.deps.actionFactories.map((factory) => factory.isCompatible(context)) + ).then((factoryCompatibility) => { + compatibleActionFactories$.next( + this.deps.actionFactories.filter((_factory, i) => { + const result = factoryCompatibility[i]; + // treat failed isCompatible checks as non-compatible + return result.status === 'fulfilled' && result.value; + }) + ); + }); + return compatibleActionFactories$.asObservable(); + } + /** * Get state object of the drilldown which is currently being created. */ @@ -478,4 +497,9 @@ export class DrilldownManagerState { public readonly useActionFactory = () => useObservable(this.actionFactory$, this.actionFactory$.getValue()); public readonly useEvents = () => useObservable(this.events$, this.events$.getValue()); + public readonly useCompatibleActionFactories = (context: BaseActionFactoryContext) => + useObservable( + useMemo(() => this.getCompatibleActionFactories(context), [context]), + undefined + ); } diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index 63f90d5a55a1f..fb2dc3ea5bd03 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -116,6 +116,7 @@ export class UiActionsServiceEnhancements licenseFeatureName, supportedTriggers, isCompatible, + isConfigurable, telemetry, extract, inject, @@ -135,7 +136,7 @@ export class UiActionsServiceEnhancements extract, inject, getIconType: () => euiIcon, - isCompatible: async () => true, + isCompatible: async (context) => !isConfigurable || isConfigurable(context), create: (serializedAction) => ({ id: '', type: factoryId, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index bc382e5d733dd..529e04c184740 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -727,6 +727,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { category: response.body.rule_type_id, license: 'basic', ruleset: 'alertsFixture', + name: 'abc', }, consumer: 'alertsFixture', numActiveAlerts: 0, diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index 6a36bf756cf19..c504811fcd0ce 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -12,7 +12,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); describe('Machine Learning', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await ml.securityCommon.createMlRoles(); diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index 08f30f8df024e..c77c4ff8d6451 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -30,6 +30,7 @@ export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + severity: CaseSeverity.LOW, connector: { id: 'none', name: 'none', diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index ddf0425fb5386..0381c46214669 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -7,7 +7,12 @@ import expect from '@kbn/expect'; import { CASES_URL } from '@kbn/cases-plugin/common/constants'; -import { CaseResponse, CaseStatuses, CommentType } from '@kbn/cases-plugin/common/api'; +import { + CaseResponse, + CaseSeverity, + CaseStatuses, + CommentType, +} from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -117,6 +122,45 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('filters by severity', async () => { + await createCase(supertest, postCaseReq); + const theCase = await createCase(supertest, postCaseReq); + const patchedCase = await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + severity: CaseSeverity.HIGH, + }, + ], + }, + }); + + const cases = await findCases({ supertest, query: { severity: CaseSeverity.HIGH } }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [patchedCase[0]], + count_open_cases: 1, + }); + }); + + it('filters by severity (none found)', async () => { + await createCase(supertest, postCaseReq); + await createCase(supertest, postCaseReq); + + const cases = await findCases({ supertest, query: { severity: CaseSeverity.CRITICAL } }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 0, + cases: [], + }); + }); + it('filters by reporters', async () => { const postedCase = await createCase(supertest, postCaseReq); const cases = await findCases({ supertest, query: { reporters: 'elastic' } }); @@ -802,6 +846,55 @@ export default ({ getService }: FtrProviderContext): void => { ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); }); }); + + describe('RBAC query filter', () => { + it('should return the correct cases when trying to query filter by severity', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', severity: CaseSeverity.HIGH }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', severity: CaseSeverity.HIGH }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', severity: CaseSeverity.HIGH }), + 200, + { + user: obsOnly, + space: 'space1', + } + ), + ]); + + // User with permissions only to security solution should get only the security solution cases + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + severity: CaseSeverity.HIGH, + }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + // Only security solution cases are being returned + ensureSavedObjectIsAuthorized(res.cases, 2, ['securitySolutionFixture']); + }); + }); }); }); }; diff --git a/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson b/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson new file mode 100644 index 0000000000000..b8a2c0f06827f --- /dev/null +++ b/x-pack/test/functional/apps/canvas/exports/8.2.workpad.ndjson @@ -0,0 +1,2 @@ +{"attributes":{"@created":"2022-05-09T15:35:33.151Z","@timestamp":"2022-05-09T15:35:57.797Z","assets":{},"colors":["#37988d","#c19628","#b83c6f","#3f9939","#1785b0","#ca5f35","#45bdb0","#f2bc33","#e74b8b","#4fbf48","#1ea6dc","#fd7643","#72cec3","#f5cc5d","#ec77a8","#7acf74","#4cbce4","#fd986f","#a1ded7","#f8dd91","#f2a4c5","#a6dfa2","#86d2ed","#fdba9f","#000000","#444444","#777777","#BBBBBB","#FFFFFF","rgba(255,255,255,0)"],"css":".canvasPage {\n\n}","height":720,"isWriteable":true,"name":"Test Canvas Workpad","page":0,"pages":[{"elements":[{"expression":"kibana\n| selectFilter\n| demodata\n| pointseries x=\"project\" y=\"sum(price)\" color=\"state\" size=\"size(username)\"\n| plot defaultStyle={seriesStyle points=5 fill=1}\n| render","filter":null,"id":"element-02d3f58d-b35a-42a1-8a91-ee6a6af99b8a","position":{"angle":0,"height":300,"left":229,"parent":null,"top":211,"width":700}}],"groups":[],"id":"page-55e10c30-e5ff-443e-bb9d-47264b477412","style":{"background":"#FFF"},"transition":{}}],"variables":[],"width":1080},"coreMigrationVersion":"8.2.0","id":"workpad-c25be373-fb42-49b5-9515-d13cbc041d46","migrationVersion":{"canvas-workpad":"8.2.0"},"references":[],"type":"canvas-workpad","updated_at":"2022-05-09T15:35:57.822Z","version":"WzEyOSwxXQ=="} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":1,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index 2c6a46b75e510..b572642f14df6 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -9,35 +9,41 @@ export default function canvasApp({ loadTestFile, getService }) { const security = getService('security'); const esArchiver = getService('esArchiver'); - describe('Canvas app', function canvasAppTestSuite() { - before(async () => { - // init data - await security.testUser.setRoles([ - 'test_logstash_reader', - 'global_canvas_all', - 'global_discover_all', - 'global_maps_all', - // TODO: Fix permission check, save and return button is disabled when dashboard is disabled - 'global_dashboard_all', - ]); - await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); - }); + describe('Canvas', function canvasAppTestSuite() { + describe('Canvas app', () => { + before(async () => { + // init data + await security.testUser.setRoles([ + 'test_logstash_reader', + 'global_canvas_all', + 'global_discover_all', + 'global_maps_all', + // TODO: Fix permission check, save and return button is disabled when dashboard is disabled + 'global_dashboard_all', + ]); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + }); - after(async () => { - await security.testUser.restoreDefaults(); + loadTestFile(require.resolve('./smoke_test')); + loadTestFile(require.resolve('./expression')); + loadTestFile(require.resolve('./filters')); + loadTestFile(require.resolve('./custom_elements')); + loadTestFile(require.resolve('./feature_controls/canvas_security')); + loadTestFile(require.resolve('./feature_controls/canvas_spaces')); + loadTestFile(require.resolve('./embeddables/lens')); + loadTestFile(require.resolve('./embeddables/maps')); + loadTestFile(require.resolve('./embeddables/saved_search')); + loadTestFile(require.resolve('./embeddables/visualization')); + loadTestFile(require.resolve('./reports')); + loadTestFile(require.resolve('./saved_object_resolve')); }); - loadTestFile(require.resolve('./smoke_test')); - loadTestFile(require.resolve('./expression')); - loadTestFile(require.resolve('./filters')); - loadTestFile(require.resolve('./custom_elements')); - loadTestFile(require.resolve('./feature_controls/canvas_security')); - loadTestFile(require.resolve('./feature_controls/canvas_spaces')); - loadTestFile(require.resolve('./embeddables/lens')); - loadTestFile(require.resolve('./embeddables/maps')); - loadTestFile(require.resolve('./embeddables/saved_search')); - loadTestFile(require.resolve('./embeddables/visualization')); - loadTestFile(require.resolve('./reports')); - loadTestFile(require.resolve('./saved_object_resolve')); + describe('Canvas management', () => { + loadTestFile(require.resolve('./migrations_smoke_test')); + }); }); } diff --git a/x-pack/test/functional/apps/canvas/migrations_smoke_test.ts b/x-pack/test/functional/apps/canvas/migrations_smoke_test.ts new file mode 100644 index 0000000000000..12a75e5a2ba8b --- /dev/null +++ b/x-pack/test/functional/apps/canvas/migrations_smoke_test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path from 'path'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); + + describe('migration smoke test', function () { + it('imports an 8.2 workpad', async function () { + /* + In 8.1 Canvas introduced by value embeddables, which requires expressions to know about embeddable migrations + Starting in 8.3, we were seeing an error during migration where it would appear that an 8.2 workpad was + from a future version. This was because there were missing embeddable migrations on the expression because + the Canvas plugin was adding the embeddable expression with all of it's migrations before other embeddables had + registered their own migrations. + + This smoke test is intended to import an 8.2 workpad to ensure that we don't hit a similar scenario in the future + */ + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + await PageObjects.savedObjects.waitTableIsLoaded(); + await PageObjects.savedObjects.importFile( + path.join(__dirname, 'exports', '8.2.workpad.ndjson') + ); + await PageObjects.savedObjects.checkImportSucceeded(); + await PageObjects.savedObjects.clickImportDone(); + }); + }); +} diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index 55282dd143b7f..d1eb2e7e03c27 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -270,6 +270,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await setupPage(); }); + afterEach(async () => { + await PageObjects.reporting.checkForReportingToasts(); + }); + it('generates a report with data', async () => { await PageObjects.discover.loadSavedSearch('Ecommerce Data'); await retry.try(async () => { diff --git a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts index dec72008d6f04..6b772c8d13c05 100644 --- a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts @@ -13,7 +13,8 @@ export default function ({ getPageObjects }: FtrProviderContext) { const xyChartContainer = 'xyVisChart'; describe('lens drag and drop tests', () => { - describe('basic drag and drop', () => { + // FLAKY: https://github.com/elastic/kibana/issues/108352 + describe.skip('basic drag and drop', () => { it('should construct the basic split xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/lens/group3/gauge.ts b/x-pack/test/functional/apps/lens/group3/gauge.ts index 9cbcbb606b423..ea029793ddfc0 100644 --- a/x-pack/test/functional/apps/lens/group3/gauge.ts +++ b/x-pack/test/functional/apps/lens/group3/gauge.ts @@ -48,6 +48,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should reflect edits for gauge', async () => { + await PageObjects.lens.switchToVisualization('horizontalBullet', 'gauge'); + await PageObjects.lens.waitForVisualization('gaugeChart'); await PageObjects.lens.configureDimension({ dimension: 'lnsGauge_metricDimensionPanel > lns-dimensionTrigger', operation: 'count', diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/advanced_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts index ba0d030cfcf6f..c809d0ee5c20d 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/advanced_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; interface Detector { identifier: string; @@ -220,7 +220,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; describe('advanced job', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/aggregated_scripted_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/aggregated_scripted_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/aggregated_scripted_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/aggregated_scripted_job.ts index 78974ecf1e64c..0740c365f02e2 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/aggregated_scripted_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/aggregated_scripted_job.ts @@ -6,7 +6,7 @@ */ import { Datafeed, Job } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -360,7 +360,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('aggregated or scripted job', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/annotations.ts b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/annotations.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts index 17c576281835a..05e38d565e969 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/annotations.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts @@ -6,14 +6,14 @@ */ import { Annotation } from '@kbn/ml-plugin/common/types/annotations'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); describe('annotations', function () { - this.tags(['mlqa']); + this.tags(['ml']); const jobId = `fq_single_1_smv_${Date.now()}`; const annotation = { diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/anomaly_explorer.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index c71f4a5789fd2..6e3e98171b109 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -6,7 +6,7 @@ */ import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; // @ts-expect-error not full interface const JOB_CONFIG: Job = { @@ -64,7 +64,7 @@ export default function ({ getService }: FtrProviderContext) { const elasticChart = getService('elasticChart'); describe('anomaly explorer', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/categorization_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/categorization_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts index 96c02f7827a58..2ee9d226596d8 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/categorization_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/categorization_job.ts @@ -6,7 +6,7 @@ */ import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '@kbn/ml-plugin/common/constants/categorization_job'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -75,7 +75,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; describe('categorization', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/categorization_small'); await ml.testResources.createIndexPatternIfNeeded('ft_categorization_small', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group1/config.ts b/x-pack/test/functional/apps/ml/anomaly_detection/config.ts similarity index 85% rename from x-pack/test/functional/apps/ml/group1/config.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/config.ts index d927f93adeffd..9078782e36f0b 100644 --- a/x-pack/test/functional/apps/ml/group1/config.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/config.ts @@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML anomaly_detection', + }, }; } diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/custom_urls.ts b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts similarity index 97% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/custom_urls.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts index 1e6e020aff69c..7920cf9721d47 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/custom_urls.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts @@ -7,12 +7,12 @@ import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; import { TIME_RANGE_TYPE } from '@kbn/ml-plugin/public/application/jobs/components/custom_url_editor/constants'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; import type { DiscoverUrlConfig, DashboardUrlConfig, OtherUrlConfig, -} from '../../../../services/ml/job_table'; +} from '../../../services/ml/job_table'; // @ts-expect-error doesn't implement the full interface const JOB_CONFIG: Job = { @@ -63,7 +63,7 @@ export default function ({ getService }: FtrProviderContext) { const browser = getService('browser'); describe('custom urls', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/date_nanos_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/date_nanos_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/date_nanos_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/date_nanos_job.ts index 4b593aacbebf1..ed9f63be66dd4 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/date_nanos_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/date_nanos_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; interface Detector { identifier: string; @@ -115,7 +115,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('job on data set with date_nanos time field', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/event_rate_nanos'); await ml.testResources.createIndexPatternIfNeeded( diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/forecasts.ts b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts similarity index 97% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/forecasts.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts index b290789419ed8..93ec331230a8a 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/forecasts.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts @@ -6,7 +6,7 @@ */ import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; // @ts-expect-error not full interface const JOB_CONFIG: Job = { @@ -40,7 +40,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('forecasts', function () { - this.tags(['mlqa']); + this.tags(['ml']); describe('with single metric job', function () { before(async () => { diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/index.ts b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts similarity index 51% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/index.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/index.ts index a1127c0e71c77..0b206bfc450f3 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/index.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts @@ -5,12 +5,35 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; -export default function ({ loadTestFile }: FtrProviderContext) { - describe('anomaly detection', function () { +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + describe('machine learning - anomaly detection', function () { this.tags(['skipFirefox']); + before(async () => { + await ml.securityCommon.createMlRoles(); + await ml.securityCommon.createMlUsers(); + }); + + after(async () => { + // NOTE: Logout needs to happen before anything else to avoid flaky behavior + await ml.securityUI.logout(); + + await ml.securityCommon.cleanMlUsers(); + await ml.securityCommon.cleanMlRoles(); + + await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); + + await ml.testResources.resetKibanaTimeZone(); + }); + loadTestFile(require.resolve('./single_metric_job')); loadTestFile(require.resolve('./single_metric_job_without_datafeed_start')); loadTestFile(require.resolve('./multi_metric_job')); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/multi_metric_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/multi_metric_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/multi_metric_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/multi_metric_job.ts index 783312b0d8608..dcb47b205bb1b 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/multi_metric_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/multi_metric_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -72,7 +72,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; describe('multi metric', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/population_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/population_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/population_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/population_job.ts index af2573e21f93d..0d04bb2ff7064 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/population_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/population_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -86,7 +86,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; describe('population', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/saved_search_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/saved_search_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/saved_search_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/saved_search_job.ts index 72dbac602cf8f..7d9c528d763d7 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/saved_search_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/saved_search_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -266,7 +266,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('saved search', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job.ts index e698dd270e1a8..cb21f8de77bd2 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -72,7 +72,7 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; describe('single metric', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job_without_datafeed_start.ts b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job_without_datafeed_start.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job_without_datafeed_start.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job_without_datafeed_start.ts index 2afa284fcc3d7..4cdea1a726fe9 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_job_without_datafeed_start.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_job_without_datafeed_start.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -57,7 +57,7 @@ export default function ({ getService }: FtrProviderContext) { } describe('single metric without datafeed start', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); diff --git a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_viewer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_viewer.ts rename to x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts index b970a0efe5602..809ebf204e2a7 100644 --- a/x-pack/test/functional/apps/ml/group2/anomaly_detection/single_metric_viewer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/single_metric_viewer.ts @@ -6,7 +6,7 @@ */ import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; // @ts-expect-error not full interface const JOB_CONFIG: Job = { @@ -41,7 +41,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('single metric viewer', function () { - this.tags(['mlqa']); + this.tags(['ml']); describe('with single metric job', function () { before(async () => { diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 0cf7c4177f057..2ba4ac6f08350 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation_saved_search.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts index cfba10c25b17b..67550ae17a4b0 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/classification_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/cloning.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts index 82f76e66b4ebd..3a33c95edba42 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { DeepPartial } from '@kbn/ml-plugin/common/types/common'; import { DataFrameAnalyticsConfig } from '@kbn/ml-plugin/public/application/data_frame_analytics/common'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/config.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/config.ts new file mode 100644 index 0000000000000..e82782f89973e --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/config.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML data_frame_analytics', + }, + }; +} diff --git a/x-pack/test/functional/apps/ml/group1/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts similarity index 64% rename from x-pack/test/functional/apps/ml/group1/index.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index 7129f3e24d4f1..19844632cc411 100644 --- a/x-pack/test/functional/apps/ml/group1/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -11,7 +11,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('machine learning - group 2', () => { + describe('machine learning - data frame analytics', function () { + this.tags(['ml', 'skipFirefox']); + before(async () => { await ml.securityCommon.createMlRoles(); await ml.securityCommon.createMlUsers(); @@ -24,22 +26,21 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlUsers(); await ml.securityCommon.cleanMlRoles(); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/bm_classification'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/ihp_outlier'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/egs_regression'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/module_sample_ecommerce'); await ml.testResources.resetKibanaTimeZone(); }); - loadTestFile(require.resolve('./permissions')); - loadTestFile(require.resolve('./pages')); - loadTestFile(require.resolve('./data_frame_analytics')); - loadTestFile(require.resolve('./model_management')); + loadTestFile(require.resolve('./outlier_detection_creation')); + loadTestFile(require.resolve('./regression_creation')); + loadTestFile(require.resolve('./classification_creation')); + loadTestFile(require.resolve('./cloning')); + loadTestFile(require.resolve('./results_view_content')); + loadTestFile(require.resolve('./regression_creation_saved_search')); + loadTestFile(require.resolve('./classification_creation_saved_search')); + loadTestFile(require.resolve('./outlier_detection_creation_saved_search')); }); } diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index e9146ce548422..947cd82cdd342 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation_saved_search.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts index 1e428531e6aa9..1dc431c74a97d 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/outlier_detection_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index a0cbd123b5169..7a84c41aa4a66 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation_saved_search.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts index 6b09b35c610a0..e22c4908486d1 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/regression_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AnalyticsTableRowDetails } from '../../../../services/ml/data_frame_analytics_table'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/results_view_content.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group1/data_frame_analytics/results_view_content.ts rename to x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts index 8d04c4897dab0..2bddf0a7d9512 100644 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/results_view_content.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts @@ -8,7 +8,7 @@ import { DeepPartial } from '@kbn/ml-plugin/common/types/common'; import { DataFrameAnalyticsConfig } from '@kbn/ml-plugin/public/application/data_frame_analytics/common'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/config.ts b/x-pack/test/functional/apps/ml/data_visualizer/config.ts index d927f93adeffd..daad4e85a1f8b 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/config.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/config.ts @@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML data_visualizer', + }, }; } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index ef15775f86204..5e529a3430606 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -208,7 +208,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('file based', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index.ts b/x-pack/test/functional/apps/ml/data_visualizer/index.ts index 973ebf2bbe3ab..a75fc8d0bf794 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index.ts @@ -12,7 +12,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); describe('machine learning - data visualizer', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); before(async () => { await ml.securityCommon.createMlRoles(); @@ -27,14 +27,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/bm_classification'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ihp_outlier'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/egs_regression'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/module_sample_ecommerce'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/module_sample_logs'); await ml.testResources.resetKibanaTimeZone(); }); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index 4334e72e9a16e..1f4c20ea6faa5 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -154,7 +154,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { } describe('index based', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/module_sample_logs'); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index b3f0e9e175d7a..c7e00f8ed5b54 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -12,7 +12,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('index based actions panel on trial license', function () { - this.tags(['mlqa']); + this.tags(['ml']); const indexPatternName = 'ft_farequote'; diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts index 6ddf3bba3a81f..0017a71a086fe 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_index_pattern_management.ts @@ -173,7 +173,7 @@ export default function ({ getService }: FtrProviderContext) { } describe('data view management', function () { - this.tags(['mlqa']); + this.tags(['ml']); const indexPatternTitle = 'ft_farequote'; before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/group1/data_frame_analytics/index.ts deleted file mode 100644 index cf9bd17f11b81..0000000000000 --- a/x-pack/test/functional/apps/ml/group1/data_frame_analytics/index.ts +++ /dev/null @@ -1,23 +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 { FtrProviderContext } from '../../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('data frame analytics', function () { - this.tags(['mlqa', 'skipFirefox']); - - loadTestFile(require.resolve('./outlier_detection_creation')); - loadTestFile(require.resolve('./regression_creation')); - loadTestFile(require.resolve('./classification_creation')); - loadTestFile(require.resolve('./cloning')); - loadTestFile(require.resolve('./results_view_content')); - loadTestFile(require.resolve('./regression_creation_saved_search')); - loadTestFile(require.resolve('./classification_creation_saved_search')); - loadTestFile(require.resolve('./outlier_detection_creation_saved_search')); - }); -} diff --git a/x-pack/test/functional/apps/ml/group1/permissions/index.ts b/x-pack/test/functional/apps/ml/group1/permissions/index.ts deleted file mode 100644 index 23d7d6fe9e2b5..0000000000000 --- a/x-pack/test/functional/apps/ml/group1/permissions/index.ts +++ /dev/null @@ -1,18 +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 { FtrProviderContext } from '../../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('permissions', function () { - this.tags(['skipFirefox']); - - loadTestFile(require.resolve('./full_ml_access')); - loadTestFile(require.resolve('./read_ml_access')); - loadTestFile(require.resolve('./no_ml_access')); - }); -} diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/index.ts b/x-pack/test/functional/apps/ml/group3/stack_management_jobs/index.ts deleted file mode 100644 index 4c4bedfeb9b76..0000000000000 --- a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/index.ts +++ /dev/null @@ -1,19 +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 { FtrProviderContext } from '../../../../ftr_provider_context'; - -export default function ({ loadTestFile }: FtrProviderContext) { - describe('stack management jobs', function () { - this.tags(['mlqa', 'skipFirefox']); - - loadTestFile(require.resolve('./synchronize')); - loadTestFile(require.resolve('./manage_spaces')); - loadTestFile(require.resolve('./import_jobs')); - loadTestFile(require.resolve('./export_jobs')); - }); -} diff --git a/x-pack/test/functional/apps/ml/group3/config.ts b/x-pack/test/functional/apps/ml/permissions/config.ts similarity index 86% rename from x-pack/test/functional/apps/ml/group3/config.ts rename to x-pack/test/functional/apps/ml/permissions/config.ts index d927f93adeffd..cc9fffd2c93f5 100644 --- a/x-pack/test/functional/apps/ml/group3/config.ts +++ b/x-pack/test/functional/apps/ml/permissions/config.ts @@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML permission', + }, }; } diff --git a/x-pack/test/functional/apps/ml/group1/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group1/permissions/full_ml_access.ts rename to x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index c632ae48b3f88..18a6e130daed0 100644 --- a/x-pack/test/functional/apps/ml/group1/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; -import { USER } from '../../../../services/ml/security_common'; +import { USER } from '../../../services/ml/security_common'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -20,7 +20,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('for user with full ML access', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); describe('with no data loaded', function () { for (const testUser of testUsers) { @@ -122,7 +122,7 @@ export default function ({ getService }: FtrProviderContext) { const ecExpectedTotalCount = '287'; const uploadFilePath = require.resolve( - '../../data_visualizer/files_to_import/artificial_server_log' + '../data_visualizer/files_to_import/artificial_server_log' ); const expectedUploadFileTitle = 'artificial_server_log'; diff --git a/x-pack/test/functional/apps/ml/group3/index.ts b/x-pack/test/functional/apps/ml/permissions/index.ts similarity index 59% rename from x-pack/test/functional/apps/ml/group3/index.ts rename to x-pack/test/functional/apps/ml/permissions/index.ts index e85b95b274720..8b28c9e6ccda4 100644 --- a/x-pack/test/functional/apps/ml/group3/index.ts +++ b/x-pack/test/functional/apps/ml/permissions/index.ts @@ -11,7 +11,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('machine learning - group 3', function () { + describe('machine learning - permissions', function () { + this.tags(['ml', 'skipFirefox']); + before(async () => { await ml.securityCommon.createMlRoles(); await ml.securityCommon.createMlUsers(); @@ -25,21 +27,14 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/bm_classification'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/ihp_outlier'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/egs_regression'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/module_sample_ecommerce'); await ml.testResources.resetKibanaTimeZone(); }); - loadTestFile(require.resolve('./feature_controls')); - loadTestFile(require.resolve('./settings')); - loadTestFile(require.resolve('./embeddables')); - loadTestFile(require.resolve('./stack_management_jobs')); + loadTestFile(require.resolve('./full_ml_access')); + loadTestFile(require.resolve('./read_ml_access')); + loadTestFile(require.resolve('./no_ml_access')); }); } diff --git a/x-pack/test/functional/apps/ml/group1/permissions/no_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts similarity index 92% rename from x-pack/test/functional/apps/ml/group1/permissions/no_ml_access.ts rename to x-pack/test/functional/apps/ml/permissions/no_ml_access.ts index 4a1c108b2fa5a..1974a48e77841 100644 --- a/x-pack/test/functional/apps/ml/group1/permissions/no_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/no_ml_access.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; -import { USER } from '../../../../services/ml/security_common'; +import { USER } from '../../../services/ml/security_common'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'error']); @@ -16,7 +16,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testUsers = [{ user: USER.ML_UNAUTHORIZED, discoverAvailable: true }]; describe('for user with no ML access', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); for (const testUser of testUsers) { describe(`(${testUser.user})`, function () { diff --git a/x-pack/test/functional/apps/ml/group1/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/permissions/read_ml_access.ts rename to x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index a18a6075055a6..301fc5102a94f 100644 --- a/x-pack/test/functional/apps/ml/group1/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; -import { USER } from '../../../../services/ml/security_common'; +import { USER } from '../../../services/ml/security_common'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -19,7 +19,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('for user with read ML access', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); describe('with no data loaded', function () { for (const testUser of testUsers) { @@ -116,7 +116,7 @@ export default function ({ getService }: FtrProviderContext) { const ecExpectedTotalCount = '287'; const uploadFilePath = require.resolve( - '../../data_visualizer/files_to_import/artificial_server_log' + '../data_visualizer/files_to_import/artificial_server_log' ); const expectedUploadFileTitle = 'artificial_server_log'; diff --git a/x-pack/test/functional/apps/ml/group2/config.ts b/x-pack/test/functional/apps/ml/short_tests/config.ts similarity index 86% rename from x-pack/test/functional/apps/ml/group2/config.ts rename to x-pack/test/functional/apps/ml/short_tests/config.ts index d927f93adeffd..33d37ecd71457 100644 --- a/x-pack/test/functional/apps/ml/group2/config.ts +++ b/x-pack/test/functional/apps/ml/short_tests/config.ts @@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML short_tests', + }, }; } diff --git a/x-pack/test/functional/apps/ml/group3/embeddables/anomaly_charts_dashboard_embeddables.ts b/x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_charts_dashboard_embeddables.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group3/embeddables/anomaly_charts_dashboard_embeddables.ts rename to x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_charts_dashboard_embeddables.ts index 68981de99fc9a..ef674c1744a51 100644 --- a/x-pack/test/functional/apps/ml/group3/embeddables/anomaly_charts_dashboard_embeddables.ts +++ b/x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_charts_dashboard_embeddables.ts @@ -34,7 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'timePicker', 'dashboard']); describe('anomaly charts in dashboard', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/apps/ml/group3/embeddables/anomaly_embeddables_migration.ts b/x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_embeddables_migration.ts similarity index 99% rename from x-pack/test/functional/apps/ml/group3/embeddables/anomaly_embeddables_migration.ts rename to x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_embeddables_migration.ts index a4c50549f5aed..8f3c30a15e543 100644 --- a/x-pack/test/functional/apps/ml/group3/embeddables/anomaly_embeddables_migration.ts +++ b/x-pack/test/functional/apps/ml/short_tests/embeddables/anomaly_embeddables_migration.ts @@ -66,7 +66,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'timePicker', 'dashboard']); describe('anomaly embeddables migration in Dashboard', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/apps/ml/group3/embeddables/constants.ts b/x-pack/test/functional/apps/ml/short_tests/embeddables/constants.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/embeddables/constants.ts rename to x-pack/test/functional/apps/ml/short_tests/embeddables/constants.ts diff --git a/x-pack/test/functional/apps/ml/group3/embeddables/index.ts b/x-pack/test/functional/apps/ml/short_tests/embeddables/index.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/embeddables/index.ts rename to x-pack/test/functional/apps/ml/short_tests/embeddables/index.ts diff --git a/x-pack/test/functional/apps/ml/group3/feature_controls/index.ts b/x-pack/test/functional/apps/ml/short_tests/feature_controls/index.ts similarity index 93% rename from x-pack/test/functional/apps/ml/group3/feature_controls/index.ts rename to x-pack/test/functional/apps/ml/short_tests/feature_controls/index.ts index ab0988c424761..657eb86e20c19 100644 --- a/x-pack/test/functional/apps/ml/group3/feature_controls/index.ts +++ b/x-pack/test/functional/apps/ml/short_tests/feature_controls/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('feature controls', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); loadTestFile(require.resolve('./ml_security')); loadTestFile(require.resolve('./ml_spaces')); }); diff --git a/x-pack/test/functional/apps/ml/group3/feature_controls/ml_security.ts b/x-pack/test/functional/apps/ml/short_tests/feature_controls/ml_security.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/feature_controls/ml_security.ts rename to x-pack/test/functional/apps/ml/short_tests/feature_controls/ml_security.ts diff --git a/x-pack/test/functional/apps/ml/group3/feature_controls/ml_spaces.ts b/x-pack/test/functional/apps/ml/short_tests/feature_controls/ml_spaces.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/feature_controls/ml_spaces.ts rename to x-pack/test/functional/apps/ml/short_tests/feature_controls/ml_spaces.ts diff --git a/x-pack/test/functional/apps/ml/short_tests/index.ts b/x-pack/test/functional/apps/ml/short_tests/index.ts new file mode 100644 index 0000000000000..3c4cbbc0677be --- /dev/null +++ b/x-pack/test/functional/apps/ml/short_tests/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + describe('machine learning - short tests', function () { + before(async () => { + await ml.securityCommon.createMlRoles(); + await ml.securityCommon.createMlUsers(); + }); + + after(async () => { + // NOTE: Logout needs to happen before anything else to avoid flaky behavior + await ml.securityUI.logout(); + + await ml.securityCommon.cleanMlUsers(); + await ml.securityCommon.cleanMlRoles(); + + await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); + + await ml.testResources.resetKibanaTimeZone(); + }); + + loadTestFile(require.resolve('./pages')); + loadTestFile(require.resolve('./model_management')); + loadTestFile(require.resolve('./feature_controls')); + loadTestFile(require.resolve('./settings')); + loadTestFile(require.resolve('./embeddables')); + }); +} diff --git a/x-pack/test/functional/apps/ml/group1/model_management/index.ts b/x-pack/test/functional/apps/ml/short_tests/model_management/index.ts similarity index 92% rename from x-pack/test/functional/apps/ml/group1/model_management/index.ts rename to x-pack/test/functional/apps/ml/short_tests/model_management/index.ts index 5595486260dee..c20957beb1ea5 100644 --- a/x-pack/test/functional/apps/ml/group1/model_management/index.ts +++ b/x-pack/test/functional/apps/ml/short_tests/model_management/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('model management', function () { - this.tags(['mlqa', 'skipFirefox']); + this.tags(['ml', 'skipFirefox']); loadTestFile(require.resolve('./model_list')); }); diff --git a/x-pack/test/functional/apps/ml/group1/model_management/model_list.ts b/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group1/model_management/model_list.ts rename to x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts diff --git a/x-pack/test/functional/apps/ml/group1/pages.ts b/x-pack/test/functional/apps/ml/short_tests/pages.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group1/pages.ts rename to x-pack/test/functional/apps/ml/short_tests/pages.ts index 2cc271e67194e..d81b5933d77df 100644 --- a/x-pack/test/functional/apps/ml/group1/pages.ts +++ b/x-pack/test/functional/apps/ml/short_tests/pages.ts @@ -11,7 +11,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('page navigation', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); before(async () => { await ml.api.cleanMlIndices(); await ml.securityUI.loginAsMlPowerUser(); diff --git a/x-pack/test/functional/apps/ml/group3/settings/calendar_creation.ts b/x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/calendar_creation.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/calendar_creation.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/calendar_delete.ts b/x-pack/test/functional/apps/ml/short_tests/settings/calendar_delete.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/calendar_delete.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/calendar_delete.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/calendar_edit.ts b/x-pack/test/functional/apps/ml/short_tests/settings/calendar_edit.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/calendar_edit.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/calendar_edit.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/common.ts b/x-pack/test/functional/apps/ml/short_tests/settings/common.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/common.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/common.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/filter_list_creation.ts b/x-pack/test/functional/apps/ml/short_tests/settings/filter_list_creation.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/filter_list_creation.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/filter_list_creation.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/filter_list_delete.ts b/x-pack/test/functional/apps/ml/short_tests/settings/filter_list_delete.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/filter_list_delete.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/filter_list_delete.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/filter_list_edit.ts b/x-pack/test/functional/apps/ml/short_tests/settings/filter_list_edit.ts similarity index 100% rename from x-pack/test/functional/apps/ml/group3/settings/filter_list_edit.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/filter_list_edit.ts diff --git a/x-pack/test/functional/apps/ml/group3/settings/index.ts b/x-pack/test/functional/apps/ml/short_tests/settings/index.ts similarity index 95% rename from x-pack/test/functional/apps/ml/group3/settings/index.ts rename to x-pack/test/functional/apps/ml/short_tests/settings/index.ts index 9ac25b7fc9483..d3f7000918a8e 100644 --- a/x-pack/test/functional/apps/ml/group3/settings/index.ts +++ b/x-pack/test/functional/apps/ml/short_tests/settings/index.ts @@ -9,7 +9,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('settings', function () { - this.tags(['mlqa', 'skipFirefox']); + this.tags(['ml', 'skipFirefox']); loadTestFile(require.resolve('./calendar_creation')); loadTestFile(require.resolve('./calendar_edit')); diff --git a/x-pack/test/functional/apps/ml/stack_management_jobs/config.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/config.ts new file mode 100644 index 0000000000000..9d0fe82b9158c --- /dev/null +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/config.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../../../config.base.js')); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - ML stack_management_jobs', + }, + }; +} diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/export_jobs.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/export_jobs.ts rename to x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts index 4ced89e35d608..c43cf74e3048c 100644 --- a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/export_jobs.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/export_jobs.ts @@ -7,7 +7,7 @@ import { Job, Datafeed } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; import type { DataFrameAnalyticsConfig } from '@kbn/ml-plugin/public/application/data_frame_analytics/common'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; const testADJobs: Array<{ job: Job; datafeed: Datafeed }> = [ { @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('export jobs', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await ml.api.cleanMlIndices(); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/anomaly_detection_jobs_7.16.json b/x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/anomaly_detection_jobs_7.16.json similarity index 100% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/anomaly_detection_jobs_7.16.json rename to x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/anomaly_detection_jobs_7.16.json diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/bad_data.json b/x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/bad_data.json similarity index 100% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/bad_data.json rename to x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/bad_data.json diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/data_frame_analytics_jobs_7.16.json b/x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/data_frame_analytics_jobs_7.16.json similarity index 100% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/files_to_import/data_frame_analytics_jobs_7.16.json rename to x-pack/test/functional/apps/ml/stack_management_jobs/files_to_import/data_frame_analytics_jobs_7.16.json diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/import_jobs.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/import_jobs.ts similarity index 97% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/import_jobs.ts rename to x-pack/test/functional/apps/ml/stack_management_jobs/import_jobs.ts index 212bb029b6e0b..e2ba704f5e109 100644 --- a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/import_jobs.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/import_jobs.ts @@ -6,7 +6,7 @@ */ import { JobType } from '@kbn/ml-plugin/common/types/saved_objects'; -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -31,7 +31,7 @@ export default function ({ getService }: FtrProviderContext) { ]; describe('import jobs', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await ml.api.cleanMlIndices(); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); diff --git a/x-pack/test/functional/apps/ml/group2/index.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/index.ts similarity index 69% rename from x-pack/test/functional/apps/ml/group2/index.ts rename to x-pack/test/functional/apps/ml/stack_management_jobs/index.ts index 4515715327e05..37f238dbeecc9 100644 --- a/x-pack/test/functional/apps/ml/group2/index.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/index.ts @@ -11,7 +11,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('machine learning - group 2', () => { + describe('machine learning - stack management jobs', function () { + this.tags(['ml', 'skipFirefox']); before(async () => { await ml.securityCommon.createMlRoles(); await ml.securityCommon.createMlUsers(); @@ -25,18 +26,16 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/bm_classification'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/ihp_outlier'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/egs_regression'); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/module_sample_ecommerce'); await ml.testResources.resetKibanaTimeZone(); }); - loadTestFile(require.resolve('./anomaly_detection')); + loadTestFile(require.resolve('./synchronize')); + loadTestFile(require.resolve('./manage_spaces')); + loadTestFile(require.resolve('./import_jobs')); + loadTestFile(require.resolve('./export_jobs')); }); } diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/manage_spaces.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/manage_spaces.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/manage_spaces.ts rename to x-pack/test/functional/apps/ml/stack_management_jobs/manage_spaces.ts index 5563bb9043c7f..e68502f4dab5a 100644 --- a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/manage_spaces.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/manage_spaces.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const browser = getService('browser'); @@ -107,7 +107,7 @@ export default function ({ getService }: FtrProviderContext) { } describe('manage spaces', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier'); diff --git a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/synchronize.ts b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts similarity index 98% rename from x-pack/test/functional/apps/ml/group3/stack_management_jobs/synchronize.ts rename to x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts index e760549b7a151..317a71ae79a0b 100644 --- a/x-pack/test/functional/apps/ml/group3/stack_management_jobs/synchronize.ts +++ b/x-pack/test/functional/apps/ml/stack_management_jobs/synchronize.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -20,7 +20,7 @@ export default function ({ getService }: FtrProviderContext) { const dfaJobIdES = 'ihp_od_es'; describe('synchronize', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier'); diff --git a/x-pack/test/functional/apps/spaces/enter_space.ts b/x-pack/test/functional/apps/spaces/enter_space.ts index 1055aa43eac39..d58a5c0f19f39 100644 --- a/x-pack/test/functional/apps/spaces/enter_space.ts +++ b/x-pack/test/functional/apps/spaces/enter_space.ts @@ -14,11 +14,8 @@ export default function enterSpaceFunctonalTests({ const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['security', 'spaceSelector']); - // FLAKY: https://github.com/elastic/kibana/issues/99879 - describe.skip('Enter Space', function () { - // FLAKY: https://github.com/elastic/kibana/issues/100570 - // These tests fail very intermittently in Firefox. Skip Firefox testing until resolved. - // this.tags('includeFirefox'); + describe('Enter Space', function () { + this.tags('includeFirefox'); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/spaces/enter_space'); await PageObjects.security.forceLogout(); diff --git a/x-pack/test/functional/apps/transform/config.ts b/x-pack/test/functional/apps/transform/config.ts index d0d07ff200281..17a471848867e 100644 --- a/x-pack/test/functional/apps/transform/config.ts +++ b/x-pack/test/functional/apps/transform/config.ts @@ -13,5 +13,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), testFiles: [require.resolve('.')], + junit: { + reportName: 'Chrome X-Pack UI Functional Tests - Transform', + }, }; } diff --git a/x-pack/test/functional/page_objects/space_selector_page.ts b/x-pack/test/functional/page_objects/space_selector_page.ts index f60da33763240..9e395ce65e36e 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.ts +++ b/x-pack/test/functional/page_objects/space_selector_page.ts @@ -48,7 +48,10 @@ export class SpaceSelectorPageObject extends FtrService { async openSpacesNav() { this.log.debug('openSpacesNav()'); - return await this.testSubjects.click('spacesNavSelector'); + return await this.retry.try(async () => { + await this.testSubjects.click('spacesNavSelector'); + await this.find.byCssSelector('#headerSpacesMenuContent'); + }); } async clickManageSpaces() { diff --git a/x-pack/test/functional/services/cases/common.ts b/x-pack/test/functional/services/cases/common.ts index 7722c32f79837..5ba0c4dbbaa57 100644 --- a/x-pack/test/functional/services/cases/common.ts +++ b/x-pack/test/functional/services/cases/common.ts @@ -7,12 +7,14 @@ import expect from '@kbn/expect'; import { CaseStatuses } from '@kbn/cases-plugin/common'; +import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../ftr_provider_context'; export function CasesCommonServiceProvider({ getService, getPageObject }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const find = getService('find'); const header = getPageObject('header'); + const common = getPageObject('common'); return { /** @@ -58,5 +60,13 @@ export function CasesCommonServiceProvider({ getService, getPageObject }: FtrPro await label.click(); await this.assertRadioGroupValue(testSubject, value); }, + + async selectSeverity(severity: CaseSeverity) { + await common.clickAndValidate( + 'case-severity-selection', + `case-severity-selection-${severity}` + ); + await testSubjects.click(`case-severity-selection-${severity}`); + }, }; } diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index 5ed22ad51ad9f..536badeee56a6 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import uuid from 'uuid'; import { FtrProviderContext } from '../../ftr_provider_context'; export function CasesCreateViewServiceProvider({ getService, getPageObject }: FtrProviderContext) { + const common = getPageObject('common'); const testSubjects = getService('testSubjects'); const find = getService('find'); const comboBox = getService('comboBox'); @@ -39,10 +41,12 @@ export function CasesCreateViewServiceProvider({ getService, getPageObject }: Ft title = 'test-' + uuid.v4(), description = 'desc' + uuid.v4(), tag = 'tagme', + severity = CaseSeverity.LOW, }: { title: string; description: string; tag: string; + severity: CaseSeverity; }) { // case name await testSubjects.setValue('input', title); @@ -54,6 +58,11 @@ export function CasesCreateViewServiceProvider({ getService, getPageObject }: Ft const descriptionArea = await find.byCssSelector('textarea.euiMarkdownEditorTextArea'); await descriptionArea.focus(); await descriptionArea.type(description); + await common.clickAndValidate( + 'case-severity-selection', + `case-severity-selection-${severity}` + ); + await testSubjects.click(`case-severity-selection-${severity}`); // save await testSubjects.click('create-case-submit'); diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index 651f52434e55f..f4d7103db0a61 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { CaseStatuses } from '@kbn/cases-plugin/common'; +import { CaseSeverityWithAll } from '@kbn/cases-plugin/common/ui'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -126,6 +127,11 @@ export function CasesTableServiceProvider({ getService, getPageObject }: FtrProv await testSubjects.click(`case-status-filter-${status}`); }, + async filterBySeverity(severity: CaseSeverityWithAll) { + await common.clickAndValidate('case-severity-filter', `case-severity-filter-${severity}`); + await testSubjects.click(`case-severity-filter-${severity}`); + }, + async filterByReporter(reporter: string) { await common.clickAndValidate( 'options-filter-popover-button-Reporter', diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts index a2a135b8cef0c..2fcdf957f8909 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index_data_visualizer_actions_panel.ts @@ -12,7 +12,7 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); describe('index based actions panel on basic license', function () { - this.tags(['mlqa']); + this.tags(['ml']); const indexPatternName = 'ft_farequote'; const savedSearch = 'ft_farequote_kuery'; diff --git a/x-pack/test/functional_basic/apps/ml/index.ts b/x-pack/test/functional_basic/apps/ml/index.ts index 0188aa0361d94..dbdab2cc0a4b2 100644 --- a/x-pack/test/functional_basic/apps/ml/index.ts +++ b/x-pack/test/functional_basic/apps/ml/index.ts @@ -12,7 +12,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); describe('machine learning basic license', function () { - this.tags(['skipFirefox', 'mlqa']); + this.tags(['skipFirefox', 'ml']); before(async () => { await ml.securityCommon.createMlRoles(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts index c5aed361aba3e..c4a7fad8224ea 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/create_case_form.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; +import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getService }: FtrProviderContext) => { @@ -30,6 +31,7 @@ export default ({ getService }: FtrProviderContext) => { title: caseTitle, description: 'test description', tag: 'tagme', + severity: CaseSeverity.HIGH, }); // validate title diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts index c64f1514b7c45..b05763cfcf079 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { CaseStatuses } from '@kbn/cases-plugin/common'; +import { CaseSeverity } from '@kbn/cases-plugin/common/api'; +import { SeverityAll } from '@kbn/cases-plugin/common/ui'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObject, getService }: FtrProviderContext) => { @@ -143,6 +145,51 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); }); + describe('severity filtering', () => { + before(async () => { + await cases.api.createCase({ severity: CaseSeverity.LOW }); + await cases.api.createCase({ severity: CaseSeverity.LOW }); + await cases.api.createCase({ severity: CaseSeverity.HIGH }); + await cases.api.createCase({ severity: CaseSeverity.HIGH }); + await cases.api.createCase({ severity: CaseSeverity.CRITICAL }); + await header.waitUntilLoadingHasFinished(); + await cases.casesTable.waitForCasesToBeListed(); + }); + beforeEach(async () => { + /** + * There is no easy way to clear the filtering. + * Refreshing the page seems to be easier. + */ + await cases.navigation.navigateToApp(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + await cases.casesTable.waitForCasesToBeDeleted(); + }); + + it('filters cases by severity', async () => { + // by default filter by all + await cases.casesTable.validateCasesTableHasNthRows(5); + + // low + await cases.casesTable.filterBySeverity(CaseSeverity.LOW); + await cases.casesTable.validateCasesTableHasNthRows(2); + + // high + await cases.casesTable.filterBySeverity(CaseSeverity.HIGH); + await cases.casesTable.validateCasesTableHasNthRows(2); + + // critical + await cases.casesTable.filterBySeverity(CaseSeverity.CRITICAL); + await cases.casesTable.validateCasesTableHasNthRows(1); + + // back to all + await cases.casesTable.filterBySeverity(SeverityAll); + await cases.casesTable.validateCasesTableHasNthRows(5); + }); + }); + describe('pagination', () => { before(async () => { await cases.api.createNthRandomCases(8); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts index a175e10fb7d18..9aaf523de6638 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/view_case.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; import { CaseStatuses } from '@kbn/cases-plugin/common'; +import { CaseSeverity } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObject, getService }: FtrProviderContext) => { @@ -184,9 +185,35 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await common.clickAndValidate('property-actions-ellipses', 'property-actions-trash'); await common.clickAndValidate('property-actions-trash', 'confirmModalConfirmButton'); await testSubjects.click('confirmModalConfirmButton'); - await testSubjects.existOrFail('cases-all-title', { timeout: 2000 }); + await header.waitUntilLoadingHasFinished(); await cases.casesTable.validateCasesTableHasNthRows(0); }); }); + + describe('Severity field', () => { + before(async () => { + await cases.navigation.navigateToApp(); + await cases.api.createNthRandomCases(1); + await cases.casesTable.waitForCasesToBeListed(); + await cases.casesTable.goToFirstListedCase(); + await header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await cases.api.deleteAllCases(); + }); + + it('shows the severity field on the sidebar', async () => { + await testSubjects.existOrFail('case-severity-selection'); + }); + it('changes the severity level from the selector', async () => { + await cases.common.selectSeverity(CaseSeverity.MEDIUM); + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('case-severity-selection-' + CaseSeverity.MEDIUM); + + // validate user action + await find.byCssSelector('[data-test-subj*="severity-update-action"]'); + }); + }); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/ml/index.ts b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts index fac9e46dcb65b..942416c73b357 100644 --- a/x-pack/test/functional_with_es_ssl/apps/ml/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/ml/index.ts @@ -12,7 +12,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); describe('ML app', function () { - this.tags(['mlqa', 'skipFirefox']); + this.tags(['ml', 'skipFirefox']); before(async () => { await ml.securityCommon.createMlRoles(); diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts index 425ce5a55524d..963acca117881 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/simple_down_alert.ts @@ -107,7 +107,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { group: 'xpack.uptime.alerts.actionGroups.monitorStatus', params: { message: - 'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} from {{state.observerLocation}} {{{state.statusMessage}}} The latest error message is {{{state.latestErrorMessage}}}', + 'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}', }, id: 'my-slack1', }, diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts index 510e94cf95f0d..9e6919c7a00e6 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/_post_urls.ts @@ -19,7 +19,7 @@ export const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = `/api/reporting/generate/print )}`; export const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent( - `(browserTimezone:America/New_York,layout:(id:print),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')` + `(browserTimezone:America/New_York,layout:(dimensions:(height:588,width:1038),id:preserve_layout),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')` )}`; export const JOB_PARAMS_CSV_DEFAULT_SPACE = `/api/reporting/generate/csv_searchsource?jobParams=${encodeURIComponent( diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts index 086f3373e2c71..e702be05f9bd8 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/usage/new_jobs.ts @@ -74,15 +74,15 @@ export default function ({ getService }: FtrProviderContext) { const usage = await usageAPI.getUsageStats(); reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1); reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2); + reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 1); + reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 1); reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0); reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2); reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1); reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0); - reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 1); + reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 1); reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0); reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2); }); diff --git a/x-pack/test/reporting_api_integration/services/usage.ts b/x-pack/test/reporting_api_integration/services/usage.ts index fd16f3859fa11..80204875cd6d6 100644 --- a/x-pack/test/reporting_api_integration/services/usage.ts +++ b/x-pack/test/reporting_api_integration/services/usage.ts @@ -101,31 +101,47 @@ export function createUsageServices({ getService }: FtrProviderContext) { }, expectRecentPdfAppStats(stats: UsageStats, app: string, count: number) { - expect( - stats.reporting.last_7_days.printable_pdf.app![app as keyof AvailableTotal['app']] - ).to.be(count); + const actual = + stats.reporting.last_7_days.printable_pdf.app![app as keyof AvailableTotal['app']]; + log.info(`expecting recent ${app} stats to have ${count} printable pdfs (actual: ${actual})`); + expect(actual).to.be(count); }, expectAllTimePdfAppStats(stats: UsageStats, app: string, count: number) { - expect(stats.reporting.printable_pdf.app![app as keyof AvailableTotal['app']]).to.be(count); + const actual = stats.reporting.printable_pdf.app![app as keyof AvailableTotal['app']]; + log.info( + `expecting all time pdf ${app} stats to have ${count} printable pdfs (actual: ${actual})` + ); + expect(actual).to.be(count); }, expectRecentPdfLayoutStats(stats: UsageStats, layout: string, count: number) { - expect(stats.reporting.last_7_days.printable_pdf.layout![layout as keyof LayoutCounts]).to.be( - count - ); + const actual = + stats.reporting.last_7_days.printable_pdf.layout![layout as keyof LayoutCounts]; + log.info(`expecting recent stats to report ${count} ${layout} layouts (actual: ${actual})`); + expect(actual).to.be(count); }, expectAllTimePdfLayoutStats(stats: UsageStats, layout: string, count: number) { - expect(stats.reporting.printable_pdf.layout![layout as keyof LayoutCounts]).to.be(count); + const actual = stats.reporting.printable_pdf.layout![layout as keyof LayoutCounts]; + log.info(`expecting all time stats to report ${count} ${layout} layouts (actual: ${actual})`); + expect(actual).to.be(count); }, expectRecentJobTypeTotalStats(stats: UsageStats, jobType: string, count: number) { - expect(stats.reporting.last_7_days[jobType as keyof JobTypes].total).to.be(count); + const actual = stats.reporting.last_7_days[jobType as keyof JobTypes].total; + log.info( + `expecting recent stats to report ${count} ${jobType} job types (actual: ${actual})` + ); + expect(actual).to.be(count); }, expectAllTimeJobTypeTotalStats(stats: UsageStats, jobType: string, count: number) { - expect(stats.reporting[jobType as keyof JobTypes].total).to.be(count); + const actual = stats.reporting[jobType as keyof JobTypes].total; + log.info( + `expecting all time stats to report ${count} ${jobType} job types (actual: ${actual})` + ); + expect(actual).to.be(count); }, getCompletedReportCount(stats: UsageStats) { diff --git a/x-pack/test/screenshot_creation/apps/ml_docs/index.ts b/x-pack/test/screenshot_creation/apps/ml_docs/index.ts index 9a12153682618..82460db174add 100644 --- a/x-pack/test/screenshot_creation/apps/ml_docs/index.ts +++ b/x-pack/test/screenshot_creation/apps/ml_docs/index.ts @@ -16,7 +16,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); describe('machine learning docs', function () { - this.tags(['mlqa']); + this.tags(['ml']); before(async () => { await ml.testResources.installAllKibanaSampleData(); diff --git a/yarn.lock b/yarn.lock index 439a288a9db59..43d8d6ce5a120 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3204,6 +3204,10 @@ version "0.0.0" uid "" +"@kbn/shared-ux-avatar-solution@link:bazel-bin/packages/shared-ux/avatar/solution": + version "0.0.0" + uid "" + "@kbn/shared-ux-button-exit-full-screen@link:bazel-bin/packages/shared-ux/button/exit_full_screen": version "0.0.0" uid "" @@ -3212,6 +3216,14 @@ version "0.0.0" uid "" +"@kbn/shared-ux-link-redirect-app@link:bazel-bin/packages/shared-ux/link/redirect_app": + version "0.0.0" + uid "" + +"@kbn/shared-ux-page-analytics-no-data@link:bazel-bin/packages/shared-ux/page/analytics_no_data": + version "0.0.0" + uid "" + "@kbn/shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services": version "0.0.0" uid "" @@ -6292,6 +6304,10 @@ version "0.0.0" uid "" +"@types/kbn__shared-ux-avatar-solution@link:bazel-bin/packages/shared-ux/avatar/solution/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__shared-ux-button-exit-full-screen@link:bazel-bin/packages/shared-ux/button/exit_full_screen/npm_module_types": version "0.0.0" uid "" @@ -6300,6 +6316,14 @@ version "0.0.0" uid "" +"@types/kbn__shared-ux-link-redirect-app@link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types": + version "0.0.0" + uid "" + +"@types/kbn__shared-ux-page-analytics-no-data@link:bazel-bin/packages/shared-ux/page/analytics_no_data/npm_module_types": + version "0.0.0" + uid "" + "@types/kbn__shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types": version "0.0.0" uid "" @@ -6660,11 +6684,6 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== -"@types/parse-link-header@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-link-header/-/parse-link-header-1.0.0.tgz#69f059e40a0fa93dc2e095d4142395ae6adc5d7a" - integrity sha512-fCA3btjE7QFeRLfcD0Sjg+6/CnmC66HpMBoRfRzd2raTaWMJV21CCZ0LO8MOqf8onl5n0EPfjq4zDhbyX8SVwA== - "@types/parse5@*", "@types/parse5@^5.0.0": version "5.0.3" resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" @@ -7096,13 +7115,6 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== -"@types/tar-fs@^1.16.1": - version "1.16.1" - resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.1.tgz#6e3fba276c173e365ae91e55f7b797a0e64298e5" - integrity sha512-uQQIaa8ukcKf/1yy2kzfP1PF+7jEZghFDKpDvgtsYo/mbqM1g4Qza1Y5oAw6kJMa7eLA/HkmxUsDqb2sWKVF9g== - dependencies: - "@types/node" "*" - "@types/tar@^4.0.5": version "4.0.5" resolved "https://registry.yarnpkg.com/@types/tar/-/tar-4.0.5.tgz#5f953f183e36a15c6ce3f336568f6051b7b183f3" @@ -8587,7 +8599,7 @@ async@^1.4.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= -async@^2.1.4, async@^2.6.2: +async@^2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== @@ -9189,7 +9201,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.9: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== -body-parser@1.19.0, body-parser@^1.18.1, body-parser@^1.18.3: +body-parser@1.19.0, body-parser@^1.18.3: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -10833,16 +10845,6 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== -connect@^3.4.0: - version "3.7.0" - resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" - integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== - dependencies: - debug "2.6.9" - finalhandler "1.1.2" - parseurl "~1.3.3" - utils-merge "1.0.1" - console-browserify@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" @@ -11006,10 +11008,10 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.6.9: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.4, core-js@^3.21.1, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: - version "3.21.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94" - integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig== +core-js@^3.0.4, core-js@^3.22.4, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: + version "3.22.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.4.tgz#f4b3f108d45736935aa028444a69397e40d8c531" + integrity sha512-1uLykR+iOfYja+6Jn/57743gc9n73EWiOnSJJ4ba3B4fOEYDBv25MagmEZBxTp5cWq4b/KPx/l77zgsp28ju4w== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -11738,9 +11740,9 @@ d3-color@1, d3-color@^1.0.3: integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== "d3-color@1 - 3", d3-color@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.0.1.tgz#03316e595955d1fcd39d9f3610ad41bb90194d0a" - integrity sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw== + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== d3-contour@^1.1.0: version "1.3.2" @@ -12041,11 +12043,6 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -dashify@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.1.0.tgz#107daf9cca5e326e30a8b39ffa5048b6684922ea" - integrity sha1-EH2vnMpeMm4wqLOf+lBItmhJIuo= - data-uri-to-buffer@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-0.0.3.tgz#18ae979a6a0ca994b0625853916d2662bbae0b1a" @@ -14434,7 +14431,7 @@ fbjs@^0.8.1, fbjs@^0.8.9: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fd-slicer@1.1.0, fd-slicer@~1.1.0: +fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= @@ -14586,7 +14583,7 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.1.2, finalhandler@~1.1.2: +finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== @@ -15384,7 +15381,7 @@ glob-to-regexp@^0.4.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob-watcher@5.0.3, glob-watcher@^5.0.3: +glob-watcher@^5.0.3: version "5.0.3" resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-5.0.3.tgz#88a8abf1c4d131eb93928994bc4a593c2e5dd626" integrity sha512-8tWsULNEPHKQ2MR4zXuzSmqbdyV5PtwwCaWSGQ1WwHsJ07ilNeN1JB8ntxhckbnpSHaf9dXFUHzIWvm1I13dsg== @@ -16408,7 +16405,7 @@ http-deceiver@^1.2.7: resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= -http-errors@1.7.2, http-errors@~1.7.0, http-errors@~1.7.2: +http-errors@1.7.2, http-errors@~1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== @@ -16579,11 +16576,6 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" -idx@^2.5.6: - version "2.5.6" - resolved "https://registry.yarnpkg.com/idx/-/idx-2.5.6.tgz#1f824595070100ae9ad585c86db08dc74f83a59d" - integrity sha512-WFXLF7JgPytbMgelpRY46nHz5tyDcedJ76pLV+RJWdb8h33bxFq4bdZau38DhNSzk5eVniBf1K3jwfK+Lb5nYA== - ieee754@^1.1.12, ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -16780,13 +16772,6 @@ inline-style-prefixer@^4.0.0: bowser "^1.7.3" css-in-js-utils "^2.0.0" -inline-style@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/inline-style/-/inline-style-2.0.0.tgz#2fa9cf624596a8109355b925094e138bbd5ea29b" - integrity sha1-L6nPYkWWqBCTVbklCU4Ti71eops= - dependencies: - dashify "^0.1.0" - inquirer@^7.0.0, inquirer@^7.3.3: version "7.3.3" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003" @@ -19234,7 +19219,7 @@ loader-utils@2.0.0, loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.0.4, loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: +loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== @@ -20266,7 +20251,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@0.0.8, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@~1.2.0: +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@~1.2.0: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -20368,13 +20353,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - mkdirp@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7" @@ -20481,16 +20459,6 @@ mock-fs@^5.1.2: resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.1.2.tgz#6fa486e06d00f8793a8d2228de980eff93ce6db7" integrity sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A== -mock-http-server@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/mock-http-server/-/mock-http-server-1.3.0.tgz#d2c2ffe65f77d3a4da8302c91d3bf687e5b51519" - integrity sha512-WC1fQ4kfOiiRZZ6IEOispJcfvz66m7VVbVFmnWsv1pOwL3psqYyLQGjFXg//zjPeZ//y/rxa8e2eh1Bb58cN7g== - dependencies: - body-parser "^1.18.1" - connect "^3.4.0" - multiparty "^4.1.2" - underscore "^1.8.3" - module-deps@^6.2.3: version "6.2.3" resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.3.tgz#15490bc02af4b56cf62299c7c17cba32d71a96ee" @@ -20642,16 +20610,6 @@ multimatch@^4.0.0: arrify "^2.0.1" minimatch "^3.0.4" -multiparty@^4.1.2: - version "4.2.1" - resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.1.tgz#d9b6c46d8b8deab1ee70c734b0af771dd46e0b13" - integrity sha512-AvESCnNoQlZiOfP9R4mxN8M9csy2L16EIbWIkt3l4FuGti9kXBS8QVzlfyg4HEnarJhrzZilgNFlZtqmoiAIIA== - dependencies: - fd-slicer "1.1.0" - http-errors "~1.7.0" - safe-buffer "5.1.2" - uid-safe "2.1.5" - murmurhash-js@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" @@ -22050,13 +22008,6 @@ parse-json@^5.0.0: json-parse-better-errors "^1.0.1" lines-and-columns "^1.1.6" -parse-link-header@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7" - integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc= - dependencies: - xtend "~4.0.1" - parse-ms@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" @@ -22232,7 +22183,7 @@ pathval@^1.1.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== -pbf@3.2.1, pbf@^3.0.5, pbf@^3.2.1: +pbf@3.2.1, pbf@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a" integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ== @@ -22251,6 +22202,13 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +pdfjs-dist@^2.13.216: + version "2.13.216" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.13.216.tgz#251a11c9c8c6db19baacd833a4e6986c517d1ab3" + integrity sha512-qn/9a/3IHIKZarTK6ajeeFXBkG15Lg1Fx99PxU09PAU2i874X8mTcHJYyDJxu7WDfNhV6hM7bRQBZU384anoqQ== + dependencies: + web-streams-polyfill "^3.2.0" + pdfmake@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.2.4.tgz#7d58d64b59f8e9b9ed0b2494b17a9d94c575825b" @@ -22379,13 +22337,6 @@ pixelmatch@^4.0.2: dependencies: pngjs "^3.0.0" -pixelmatch@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.1.0.tgz#b640f0e5a03a09f235a4b818ef3b9b98d9d0b911" - integrity sha512-HqtgvuWN12tBzKJf7jYsc38Ha28Q2NYpmBL9WostEGgDHJqbTLkjydZXL1ZHM02ZnB+Dkwlxo87HBY38kMiD6A== - dependencies: - pngjs "^3.4.0" - pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -23603,11 +23554,6 @@ randexp@0.4.6: discontinuous-range "1.0.0" ret "~0.1.10" -random-bytes@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" - integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= - random-word-slugs@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/random-word-slugs/-/random-word-slugs-0.0.5.tgz#6ccd6c7ea320be9fbc19507f8c3a7d4a970ff61f" @@ -25618,16 +25564,6 @@ sass-loader@^10.2.0: schema-utils "^3.0.0" semver "^7.3.2" -sass-resources-loader@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/sass-resources-loader/-/sass-resources-loader-2.0.1.tgz#c8427f3760bf7992f24f27d3889a1c797e971d3a" - integrity sha512-UsjQWm01xglINC1kPidYwKOBBzOElVupm9RwtOkRlY0hPA4GKi2KFsn4BZypRD1kudaXgUnGnfbiVOE7c+ybAg== - dependencies: - async "^2.1.4" - chalk "^1.1.3" - glob "^7.1.1" - loader-utils "^1.0.4" - save-pixels@^2.3.2: version "2.3.4" resolved "https://registry.yarnpkg.com/save-pixels/-/save-pixels-2.3.4.tgz#49d349c06b8d7c0127dbf0da24b44aca5afb59fe" @@ -27478,11 +27414,6 @@ syntax-error@^1.1.1: dependencies: acorn-node "^1.2.0" -tabbable@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.3.tgz#0e4ee376f3631e42d7977a074dbd2b3827843081" - integrity sha512-nOWwx35/JuDI4ONuF0ZTo6lYvI0fY0tZCH1ErzY2EXfu4az50ZyiUX8X073FLiZtmWUVlkRnuXsehjJgCw9tYg== - tabbable@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.2.1.tgz#e3fda7367ddbb172dcda9f871c0fdb36d1c4cd9c" @@ -27567,16 +27498,6 @@ tar-fs@^2.0.0, tar-fs@^2.1.1: pump "^3.0.0" tar-stream "^2.1.4" -tar-fs@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.0.tgz#d1cdd121ab465ee0eb9ccde2d35049d3f3daf0d5" - integrity sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.0.0" - tar-stream@^2.0.0: version "2.1.3" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.3.tgz#1e2022559221b7866161660f118255e20fa79e41" @@ -28447,13 +28368,6 @@ uglify-to-browserify@~1.0.0: resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc= -uid-safe@2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" - integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== - dependencies: - random-bytes "~1.0.0" - umd@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.3.tgz#aa9fe653c42b9097678489c01000acb69f0b26cf" @@ -28506,7 +28420,7 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== -underscore@^1.13.1, underscore@^1.8.3: +underscore@^1.13.1: version "1.13.1" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== @@ -29726,15 +29640,6 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw== -vt-pbf@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.1.tgz#b0f627e39a10ce91d943b898ed2363d21899fb82" - integrity sha512-pHjWdrIoxurpmTcbfBWXaPwSmtPAHS105253P1qyEfSTV2HJddqjM+kIHquaT/L6lVJIk9ltTGc0IxR/G47hYA== - dependencies: - "@mapbox/point-geometry" "0.1.0" - "@mapbox/vector-tile" "^1.3.1" - pbf "^3.0.5" - vt-pbf@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/vt-pbf/-/vt-pbf-3.1.3.tgz#68fd150756465e2edae1cc5c048e063916dcfaac" @@ -29859,6 +29764,11 @@ web-streams-polyfill@^3.0.0: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.0.1.tgz#1f836eea307e8f4af15758ee473c7af755eb879e" integrity sha512-M+EmTdszMWINywOZaqpZ6VIEDUmNpRaTOuizF0ZKPjSDC8paMRe/jBBwFv0Yeyn5WYnM5pMqMQa82vpaE+IJRw== +web-streams-polyfill@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965" + integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"