diff --git a/.eslintrc.js b/.eslintrc.js index 56c06902e062..f1e0b7d9353e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -112,7 +112,6 @@ module.exports = { files: ['x-pack/plugins/lens/**/*.{js,ts,tsx}'], rules: { 'react-hooks/exhaustive-deps': 'off', - 'react-hooks/rules-of-hooks': 'off', }, }, { diff --git a/.i18nrc.json b/.i18nrc.json index 3b2e628f7226..034b9da799d3 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -34,7 +34,7 @@ "kibana_utils": "src/plugins/kibana_utils", "navigation": "src/plugins/navigation", "newsfeed": "src/plugins/newsfeed", - "regionMap": "src/legacy/core_plugins/region_map", + "regionMap": "src/plugins/region_map", "savedObjects": "src/plugins/saved_objects", "savedObjectsManagement": "src/plugins/saved_objects_management", "server": "src/legacy/server", diff --git a/docs/apm/images/apm-service-map-anomaly.png b/docs/apm/images/apm-service-map-anomaly.png new file mode 100644 index 000000000000..b661e8f09d1a Binary files /dev/null and b/docs/apm/images/apm-service-map-anomaly.png differ diff --git a/docs/apm/images/green-service.png b/docs/apm/images/green-service.png new file mode 100644 index 000000000000..bbc00a3543b0 Binary files /dev/null and b/docs/apm/images/green-service.png differ diff --git a/docs/apm/images/red-service.png b/docs/apm/images/red-service.png new file mode 100644 index 000000000000..be7a62b1774a Binary files /dev/null and b/docs/apm/images/red-service.png differ diff --git a/docs/apm/images/service-maps.png b/docs/apm/images/service-maps.png index 454ae9bb720f..d4272e899999 100644 Binary files a/docs/apm/images/service-maps.png and b/docs/apm/images/service-maps.png differ diff --git a/docs/apm/images/yellow-service.png b/docs/apm/images/yellow-service.png new file mode 100644 index 000000000000..43afd6250be7 Binary files /dev/null and b/docs/apm/images/yellow-service.png differ diff --git a/docs/apm/machine-learning.asciidoc b/docs/apm/machine-learning.asciidoc index 9d347fc4f111..03f7e13c9857 100644 --- a/docs/apm/machine-learning.asciidoc +++ b/docs/apm/machine-learning.asciidoc @@ -6,13 +6,20 @@ Integrate with machine learning ++++ -The Machine Learning integration will initiate a new job predefined to calculate anomaly scores on transaction response times. -The response time graph will show the expected bounds and add an annotation when the anomaly score is 75 or above. -Jobs can be created per transaction type, and based on the average response time. -Manage jobs in the *Machine Learning jobs management*. +The Machine Learning integration initiates a new job predefined to calculate anomaly scores on APM transaction durations. +Jobs can be created per transaction type, and are based on the service's average response time. + +After a machine learning job is created, results are shown in two places: + +The transaction duration graph will show the expected bounds and add an annotation when the anomaly score is 75 or above. + +[role="screenshot"] +image::apm/images/apm-ml-integration.png[Example view of anomaly scores on response times in the APM app] + +Service maps will display a color-coded anomaly indicator based on the detected anomaly score. [role="screenshot"] -image::apm/images/apm-ml-integration.png[Example view of anomaly scores on response times in APM app in Kibana] +image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] [float] [[create-ml-integration]] @@ -20,8 +27,10 @@ image::apm/images/apm-ml-integration.png[Example view of anomaly scores on respo To enable machine learning anomaly detection, first choose a service to monitor. Then, select **Integrations** > **Enable ML anomaly detection** and click **Create job**. + That's it! After a few minutes, the job will begin calculating results; it might take additional time for results to appear on your graph. +Jobs can be managed in *Machine Learning jobs management*. APM specific anomaly detection wizards are also available for certain Agents. See the machine learning {ml-docs}/ootb-ml-jobs-apm.html[APM anomaly detection configurations] for more information. diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index be86b9d522ac..3a6a96fca9d0 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -9,7 +9,9 @@ Please use Chrome or Firefox if available. A service map is a real-time visual representation of the instrumented services in your application's architecture. It shows you how these services are connected, along with high-level metrics like average transaction duration, -requests per minute, and errors per minute, that allow you to quickly assess the status of your services. +requests per minute, and errors per minute. +If enabled, service maps also integrate with machine learning--for real time health indicators based on anomaly detection scores. +All of these features can help you to quickly and visually assess the status and health of your services. We currently surface two types of service maps: @@ -52,6 +54,26 @@ Additional filters are not currently available for service maps. [role="screenshot"] image::apm/images/service-maps-java.png[Example view of service maps with Java highlighted in the APM app in Kibana] +[float] +[[service-map-anomaly-detection]] +=== Anomaly detection with machine learning + +Machine learning jobs can be created to calculate anomaly scores on APM transaction durations within the selected service. +When these jobs are active, service maps will display a color-coded anomaly indicator based on the detected anomaly score: + +[horizontal] +image:apm/images/green-service.png[APM green service]:: Max anomaly score **<=25**. Service is healthy. +image:apm/images/yellow-service.png[APM yellow service]:: Max anomaly score **26-74**. Anomalous activity detected. Service may be degraded. +image:apm/images/red-service.png[APM red service]:: Max anomaly score **>=75**. Anomalous activity detected. Service is unhealthy. + +[role="screenshot"] +image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] + +If an anomaly has been detected, click *view anomalies* to view the anomaly detection metric viewier in the Machine learning app. +This time series analysis will display additional details on the severity and time of the detected anomalies. + +To learn how to create a machine learning job, see <>. + [float] [[service-maps-legend]] === Legend diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index 21a155ba977c..60cbfd30e667 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -28,7 +28,6 @@ export declare class IndexPattern implements IIndexPattern | [formatHit](./kibana-plugin-plugins-data-public.indexpattern.formathit.md) | | any | | | [id](./kibana-plugin-plugins-data-public.indexpattern.id.md) | | string | | | [metaFields](./kibana-plugin-plugins-data-public.indexpattern.metafields.md) | | string[] | | -| [routes](./kibana-plugin-plugins-data-public.indexpattern.routes.md) | | {
edit: string;
addField: string;
indexedFields: string;
scriptedFields: string;
sourceFilters: string;
} | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpattern.timefieldname.md) | | string | undefined | | | [title](./kibana-plugin-plugins-data-public.indexpattern.title.md) | | string | | | [type](./kibana-plugin-plugins-data-public.indexpattern.type.md) | | string | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.routes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.routes.md deleted file mode 100644 index 81e7abd4f960..000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.routes.md +++ /dev/null @@ -1,17 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [routes](./kibana-plugin-plugins-data-public.indexpattern.routes.md) - -## IndexPattern.routes property - -Signature: - -```typescript -get routes(): { - edit: string; - addField: string; - indexedFields: string; - scriptedFields: string; - sourceFilters: string; - }; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md index fa97666a61b9..39c8b0a700c8 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatterns.md @@ -18,7 +18,6 @@ indexPatterns: { validate: typeof validateIndexPattern; getFromSavedObject: typeof getFromSavedObject; flattenHitWrapper: typeof flattenHitWrapper; - getRoutes: typeof getRoutes; formatHitProvider: typeof formatHitProvider; } ``` diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 689d870d9cad..81b4e210961f 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -24,17 +24,17 @@ Password:: password for 'login' type authentication. [source,text] -- - id: 'my-email' - name: preconfigured-email-action-type - actionTypeId: .email - config: - from: testsender@test.com <1.1> - host: validhostname <1.2> - port: 8080 <1.3> - secure: false <1.4> - secrets: - user: testuser <2.1> - password: passwordkeystorevalue <2.2> + my-email: + name: preconfigured-email-action-type + actionTypeId: .email + config: + from: testsender@test.com <1.1> + host: validhostname <1.2> + port: 8080 <1.3> + secure: false <1.4> + secrets: + user: testuser <2.1> + password: passwordkeystorevalue <2.2> -- `config` defines the action type specific to the configuration and contains the following properties: diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 4f5254e3311d..c71412210c53 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -21,13 +21,13 @@ Execution time field:: This field will be automatically set to the time the ale [source,text] -- - id: 'my-index' - name: action-type-index - actionTypeId: .index - config: - index: .kibana <1> - refresh: true <2> - executionTimeField: somedate <3> + my-index: + name: action-type-index + actionTypeId: .index + config: + index: .kibana <1> + refresh: true <2> + executionTimeField: somedate <3> -- `config` defines the action type specific to the configuration and contains the following properties: diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index 957c035b028f..cd51ec2e3301 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -141,13 +141,13 @@ Integration Key:: A 32 character PagerDuty Integration Key for an integration [source,text] -- - id: 'my-pagerduty' - name: preconfigured-pagerduty-action-type - actionTypeId: .pagerduty - config: - apiUrl: https://test.host <1.1> - secrets: - routingKey: testroutingkey <2.1> + my-pagerduty: + name: preconfigured-pagerduty-action-type + actionTypeId: .pagerduty + config: + apiUrl: https://test.host <1.1> + secrets: + routingKey: testroutingkey <2.1> -- `config` defines the action type specific to the configuration and contains the following properties: diff --git a/docs/user/alerting/action-types/server-log.asciidoc b/docs/user/alerting/action-types/server-log.asciidoc index f08dbe5542f0..eadca229bc19 100644 --- a/docs/user/alerting/action-types/server-log.asciidoc +++ b/docs/user/alerting/action-types/server-log.asciidoc @@ -18,9 +18,9 @@ Name:: The name of the connector. The name is used to identify a connector [source,text] -- - id: 'my-server-log' - name: test - actionTypeId: .server-log + my-server-log: + name: test + actionTypeId: .server-log -- [float] diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 195093536bc0..afa616ba77b3 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -19,11 +19,11 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa [source,text] -- - id: 'my-slack' - name: preconfigured-slack-action-type - actionTypeId: .slack - config: - webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' <1> + my-slack: + name: preconfigured-slack-action-type + actionTypeId: .slack + config: + webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' <1> -- `config` defines the action type specific to the configuration and contains the following properties: diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc index f4c108426642..27609652288b 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -23,17 +23,17 @@ Password:: An optional password. If set, HTTP basic authentication is used. Cur [source,text] -- - id: 'my-webhook' - name: preconfigured-webhook-action-type - actionTypeId: .webhook - config: - url: https://test.host <1.1> - method: POST <1.2> - headers: <1.3> - testheader: testvalue - secrets: - user: testuser <2.1> - password: passwordkeystorevalue <2.2> + my-webhook: + name: preconfigured-webhook-action-type + actionTypeId: .webhook + config: + url: https://test.host <1.1> + method: POST <1.2> + headers: <1.3> + testheader: testvalue + secrets: + user: testuser <2.1> + password: passwordkeystorevalue <2.2> -- `config` defines the action type specific to the configuration and contains the following properties: diff --git a/docs/user/alerting/pre-configured-connectors.asciidoc b/docs/user/alerting/pre-configured-connectors.asciidoc index 5ff4ea15df56..d5c20d1853d4 100644 --- a/docs/user/alerting/pre-configured-connectors.asciidoc +++ b/docs/user/alerting/pre-configured-connectors.asciidoc @@ -25,12 +25,12 @@ The following example shows a valid configuration of two out-of-the box connecto ```js xpack.actions.preconfigured: - - id: 'my-slack1' <1> + my-slack1: <1> actionTypeId: .slack <2> name: 'Slack #xyz' <3> config: <4> webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' - - id: 'webhook-service' + webhook-service: actionTypeId: .webhook name: 'Email service' config: @@ -44,7 +44,7 @@ The following example shows a valid configuration of two out-of-the box connecto password: changeme ``` -<1> `id` is the action connector identifier. +<1> the key is the action connector identifier, eg `my-slack1` in this example. <2> `actionTypeId` is the action type identifier. <3> `name` is the name of the preconfigured connector. <4> `config` is the action type specific to the configuration. @@ -69,7 +69,7 @@ The following example shows a valid configuration of preconfigured action type w ```js xpack.actions.enabledActionTypes: ['.slack', '.email', '.index'] <1> xpack.actions.preconfigured: <2> - - id: 'my-server-log' + my-server-log: actionTypeId: .server-log name: 'Server log #xyz' ``` diff --git a/docs/visualize/tsvb.asciidoc b/docs/visualize/tsvb.asciidoc index 36709c2cc643..9a1e81670b65 100644 --- a/docs/visualize/tsvb.asciidoc +++ b/docs/visualize/tsvb.asciidoc @@ -122,3 +122,17 @@ Edit the source for the Markdown visualization. . To insert the mustache template variable into the editor, click the variable name. + The http://mustache.github.io/mustache.5.html[mustache syntax] uses the Handlebar.js processor, which is an extended version of the Mustache template language. + +[float] +[[tsvb-style-markdown]] +==== Style Markdown text + +Style your Markdown visualization using http://lesscss.org/features/[less syntax]. + +. Select *Markdown*. + +. Select *Panel options*. + +. Enter styling rules in *Custom CSS* section ++ +Less in TSVB does not support custom plugins or inline JavaScript. diff --git a/package.json b/package.json index 8a92b4648930..0c83cb429b65 100644 --- a/package.json +++ b/package.json @@ -210,7 +210,7 @@ "leaflet-responsive-popup": "0.6.4", "leaflet-vega": "^0.8.6", "leaflet.heat": "0.2.0", - "less": "^2.7.3", + "less": "npm:@elastic/less@2.7.3-kibana", "less-loader": "5.0.0", "lodash": "npm:@elastic/lodash@3.10.1-kibana4", "lodash.clonedeep": "^4.5.0", diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/README.md b/packages/kbn-dev-utils/src/ci_stats_reporter/README.md index 6133f9871699..c7b98224c4e5 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/README.md +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/README.md @@ -8,7 +8,7 @@ This class integrates with the `ciStats.trackBuild {}` Jenkins Pipeline function To create an instance of the reporter, import the class and call `CiStatsReporter.fromEnv(log)` (passing it a tooling log). -#### `CiStatsReporter#metric(name: string, subName: string, value: number)` +#### `CiStatsReporter#metrics(metrics: Array<{ group: string, id: string, value: number }>)` Use this method to record metrics in the Kibana CI Stats service. @@ -19,5 +19,11 @@ import { CiStatsReporter, ToolingLog } from '@kbn/dev-utils'; const log = new ToolingLog(...); const reporter = CiStatsReporter.fromEnv(log) -reporter.metric('Build speed', specificBuildName, timeToRunBuild) +reporter.metrics([ + { + group: 'Build size', + id: specificBuildName, + value: sizeOfBuild + } +]) ``` \ No newline at end of file diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index 5fe1844a8556..4e9128961043 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -84,13 +84,16 @@ export class CiStatsReporter { return !!this.config; } - async metric(name: string, subName: string, value: number) { + async metrics(metrics: Array<{ group: string; id: string; value: number }>) { if (!this.config) { return; } let attempt = 0; const maxAttempts = 5; + const bodySummary = metrics + .map(({ group, id, value }) => `[${group}/${id}=${value}]`) + .join(' '); while (true) { attempt += 1; @@ -98,18 +101,14 @@ export class CiStatsReporter { try { await Axios.request({ method: 'POST', - url: '/metric', + url: '/v1/metrics', baseURL: this.config.apiUrl, - params: { - buildId: this.config.buildId, - }, headers: { Authorization: `token ${this.config.apiToken}`, }, data: { - name, - subName, - value, + buildId: this.config.buildId, + metrics, }, }); @@ -125,14 +124,14 @@ export class CiStatsReporter { this.log.warning( `error recording metric [status=${error.response.status}] [resp=${inspect( error.response.data - )}] [${name}/${subName}=${value}]` + )}] ${bodySummary}` ); return; } if (attempt === maxAttempts) { this.log.warning( - `failed to reach kibana-ci-stats service too many times, unable to record metric [${name}/${subName}=${value}]` + `failed to reach kibana-ci-stats service too many times, unable to record metric ${bodySummary}` ); return; } diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index e46075eff63a..a2fbe969e34d 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -21,7 +21,7 @@ import 'source-map-support/register'; import Path from 'path'; -import { run, REPO_ROOT, createFlagError, createFailError, CiStatsReporter } from '@kbn/dev-utils'; +import { run, REPO_ROOT, createFlagError, CiStatsReporter } from '@kbn/dev-utils'; import { logOptimizerState } from './log_optimizer_state'; import { OptimizerConfig } from './optimizer'; @@ -82,9 +82,9 @@ run( throw createFlagError('expected --scan-dir to be a string'); } - const reportStatsName = flags['report-stats']; - if (reportStatsName !== undefined && typeof reportStatsName !== 'string') { - throw createFlagError('expected --report-stats to be a string'); + const reportStats = flags['report-stats'] ?? false; + if (typeof reportStats !== 'boolean') { + throw createFlagError('expected --report-stats to have no value'); } const config = OptimizerConfig.create({ @@ -103,22 +103,32 @@ run( let update$ = runOptimizer(config); - if (reportStatsName) { + if (reportStats) { const reporter = CiStatsReporter.fromEnv(log); if (!reporter.isEnabled()) { - throw createFailError('Unable to initialize CiStatsReporter from env'); + log.warning('Unable to initialize CiStatsReporter from env'); } - update$ = update$.pipe(reportOptimizerStats(reporter, reportStatsName)); + update$ = update$.pipe(reportOptimizerStats(reporter, config)); } await update$.pipe(logOptimizerState(log, config)).toPromise(); }, { flags: { - boolean: ['core', 'watch', 'oss', 'examples', 'dist', 'cache', 'profile', 'inspect-workers'], - string: ['workers', 'scan-dir', 'report-stats'], + boolean: [ + 'core', + 'watch', + 'oss', + 'examples', + 'dist', + 'cache', + 'profile', + 'inspect-workers', + 'report-stats', + ], + string: ['workers', 'scan-dir'], default: { core: true, examples: true, @@ -136,7 +146,7 @@ run( --dist create bundles that are suitable for inclusion in the Kibana distributable --scan-dir add a directory to the list of directories scanned for plugins (specify as many times as necessary) --no-inspect-workers when inspecting the parent process, don't inspect the workers - --report-stats=[name] attempt to report stats about this execution of the build to the kibana-ci-stats service using this name + --report-stats attempt to report stats about this execution of the build to the kibana-ci-stats service using this name `, }, } diff --git a/packages/kbn-optimizer/src/report_optimizer_stats.ts b/packages/kbn-optimizer/src/report_optimizer_stats.ts index 375978b9b794..06161fb2567b 100644 --- a/packages/kbn-optimizer/src/report_optimizer_stats.ts +++ b/packages/kbn-optimizer/src/report_optimizer_stats.ts @@ -21,10 +21,10 @@ import { materialize, mergeMap, dematerialize } from 'rxjs/operators'; import { CiStatsReporter } from '@kbn/dev-utils'; import { OptimizerUpdate$ } from './run_optimizer'; -import { OptimizerState } from './optimizer'; +import { OptimizerState, OptimizerConfig } from './optimizer'; import { pipeClosure } from './common'; -export function reportOptimizerStats(reporter: CiStatsReporter, name: string) { +export function reportOptimizerStats(reporter: CiStatsReporter, config: OptimizerConfig) { return pipeClosure((update$: OptimizerUpdate$) => { let lastState: OptimizerState | undefined; return update$.pipe( @@ -35,7 +35,18 @@ export function reportOptimizerStats(reporter: CiStatsReporter, name: string) { } if (n.kind === 'C' && lastState) { - await reporter.metric('@kbn/optimizer build time', name, lastState.durSec); + await reporter.metrics( + config.bundles.map(bundle => { + // make the cache read from the cache file since it was likely updated by the worker + bundle.cache.refresh(); + + return { + group: `@kbn/optimizer bundle module count`, + id: bundle.id, + value: bundle.cache.getModuleCount() || 0, + }; + }) + ); } return n; diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 95e826e7620a..49bcc6e7e704 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -137,9 +137,9 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { // or which have require() statements that should be ignored because the file is // already bundled with all its necessary depedencies noParse: [ - /[\///]node_modules[\///]elasticsearch-browser[\///]/, - /[\///]node_modules[\///]lodash[\///]index\.js$/, - /[\///]node_modules[\///]vega-lib[\///]build[\///]vega\.js$/, + /[\/\\]node_modules[\/\\]elasticsearch-browser[\/\\]/, + /[\/\\]node_modules[\/\\]lodash[\/\\]index\.js$/, + /[\/\\]node_modules[\/\\]vega-lib[\/\\]build[\/\\]vega\.js$/, ], rules: [ diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 28cf36dedba3..1b70cced4a5c 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -43933,30 +43933,29 @@ class CiStatsReporter { isEnabled() { return !!this.config; } - async metric(name, subName, value) { + async metrics(metrics) { var _a, _b, _c, _d; if (!this.config) { return; } let attempt = 0; const maxAttempts = 5; + const bodySummary = metrics + .map(({ group, id, value }) => `[${group}/${id}=${value}]`) + .join(' '); while (true) { attempt += 1; try { await axios_1.default.request({ method: 'POST', - url: '/metric', + url: '/v1/metrics', baseURL: this.config.apiUrl, - params: { - buildId: this.config.buildId, - }, headers: { Authorization: `token ${this.config.apiToken}`, }, data: { - name, - subName, - value, + buildId: this.config.buildId, + metrics, }, }); return; @@ -43968,11 +43967,11 @@ class CiStatsReporter { } if (((_b = error) === null || _b === void 0 ? void 0 : _b.response) && error.response.status !== 502) { // error response from service was received so warn the user and move on - this.log.warning(`error recording metric [status=${error.response.status}] [resp=${util_1.inspect(error.response.data)}] [${name}/${subName}=${value}]`); + this.log.warning(`error recording metric [status=${error.response.status}] [resp=${util_1.inspect(error.response.data)}] ${bodySummary}`); return; } if (attempt === maxAttempts) { - this.log.warning(`failed to reach kibana-ci-stats service too many times, unable to record metric [${name}/${subName}=${value}]`); + this.log.warning(`failed to reach kibana-ci-stats service too many times, unable to record metric ${bodySummary}`); return; } // we failed to reach the backend and we have remaining attempts, lets retry after a short delay diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 8442f1ecc641..fd496da26283 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -114,7 +114,9 @@ export class ApplicationService { context, http: { basePath }, injectedMetadata, - redirectTo = (path: string) => (window.location.href = path), + redirectTo = (path: string) => { + window.location.assign(path); + }, history, }: SetupDeps): InternalApplicationSetup { const basename = basePath.get(); @@ -210,7 +212,10 @@ export class ApplicationService { } const appBasePath = basePath.prepend(appRoute); - const mount: LegacyAppMounter = () => redirectTo(appBasePath); + const mount: LegacyAppMounter = ({ history: appHistory }) => { + redirectTo(appHistory.createHref(appHistory.location)); + window.location.reload(); + }; const { updater$, ...appProps } = app; this.apps.set(app.id, { diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx index 60c36d3e330e..e399fbc72697 100644 --- a/src/core/public/application/integration_tests/application_service.test.tsx +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -18,8 +18,10 @@ */ import { take } from 'rxjs/operators'; -import { createRenderer } from './utils'; +import { act } from 'react-dom/test-utils'; import { createMemoryHistory, MemoryHistory } from 'history'; + +import { createRenderer } from './utils'; import { ApplicationService } from '../application_service'; import { httpServiceMock } from '../../http/http_service.mock'; import { contextServiceMock } from '../../context/context_service.mock'; @@ -27,6 +29,9 @@ import { injectedMetadataServiceMock } from '../../injected_metadata/injected_me import { MockLifecycle } from '../test_types'; import { overlayServiceMock } from '../../overlays/overlay_service.mock'; import { AppMountParameters } from '../types'; +import { ScopedHistory } from '../scoped_history'; + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); describe('ApplicationService', () => { let setupDeps: MockLifecycle<'setup'>; @@ -83,7 +88,10 @@ describe('ApplicationService', () => { expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); - resolveMount!(); + await act(async () => { + resolveMount!(); + await flushPromises(); + }); expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); }); @@ -109,7 +117,7 @@ describe('ApplicationService', () => { const { navigateToApp, currentAppId$ } = await service.start(startDeps); - await navigateToApp('app1'); + await act(() => navigateToApp('app1')); expect(await currentAppId$.pipe(take(1)).toPromise()).toEqual('app1'); @@ -120,6 +128,46 @@ describe('ApplicationService', () => { }); }); + it('redirects to full path when navigating to legacy app', async () => { + const redirectTo = jest.fn(); + const reloadSpy = jest.spyOn(window.location, 'reload').mockImplementation(() => {}); + + // In the real application, we use a BrowserHistory instance configured with `basename`. However, in tests we must + // use MemoryHistory which does not support `basename`. In order to emulate this behavior, we will wrap this + // instance with a ScopedHistory configured with a basepath. + history.push(setupDeps.http.basePath.get()); // ScopedHistory constructor will fail if underlying history is not currently at basePath. + const { register, registerLegacyApp } = service.setup({ + ...setupDeps, + redirectTo, + history: new ScopedHistory(history, setupDeps.http.basePath.get()), + }); + + register(Symbol(), { + id: 'app1', + title: 'App1', + mount: ({ onAppLeave }: AppMountParameters) => { + onAppLeave(actions => actions.default()); + return () => undefined; + }, + }); + registerLegacyApp({ + id: 'myLegacyTestApp', + appUrl: '/app/myLegacyTestApp', + title: 'My Legacy Test App', + }); + + const { navigateToApp, getComponent } = await service.start(startDeps); + + update = createRenderer(getComponent()); + + await navigate('/test/app/app1'); + await act(() => navigateToApp('myLegacyTestApp', { path: '#/some-path' })); + + expect(redirectTo).toHaveBeenCalledWith('/test/app/myLegacyTestApp#/some-path'); + expect(reloadSpy).toHaveBeenCalled(); + reloadSpy.mockRestore(); + }); + describe('leaving an application that registered an app leave handler', () => { it('navigates to the new app if action is default', async () => { startDeps.overlays.openConfirm.mockResolvedValue(true); @@ -146,8 +194,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).not.toHaveBeenCalled(); expect(history.entries.length).toEqual(3); @@ -179,8 +229,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( @@ -216,8 +268,10 @@ describe('ApplicationService', () => { update = createRenderer(getComponent()); - await navigate('/app/app1'); - await navigateToApp('app2'); + await act(async () => { + await navigate('/app/app1'); + await navigateToApp('app2'); + }); expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(1); expect(startDeps.overlays.openConfirm).toHaveBeenCalledWith( diff --git a/src/core/server/logging/README.md b/src/core/server/logging/README.md index ed64e7c4ce0b..553dc7c36e82 100644 --- a/src/core/server/logging/README.md +++ b/src/core/server/logging/README.md @@ -167,7 +167,7 @@ logging: - context: plugins appenders: [custom] level: warn - - context: plugins.pid + - context: plugins.myPlugin level: info - context: server level: fatal @@ -180,14 +180,14 @@ logging: Here is what we get with the config above: -| Context | Appenders | Level | -| ------------- |:------------------------:| -----:| -| root | console, file | error | -| plugins | custom | warn | -| plugins.pid | custom | info | -| server | console, file | fatal | -| optimize | console | error | -| telemetry | json-file-appender | all | +| Context | Appenders | Level | +| ---------------- |:------------------------:| -----:| +| root | console, file | error | +| plugins | custom | warn | +| plugins.myPlugin | custom | info | +| server | console, file | fatal | +| optimize | console | error | +| telemetry | json-file-appender | all | The `root` logger has a dedicated configuration node since this context is special and should always exist. By @@ -259,7 +259,7 @@ define a custom one. ```yaml logging: loggers: - - context: your-plugin + - context: plugins.myPlugin appenders: [console] ``` Logs in a *file* if given file path. You should define a custom appender with `kind: file` @@ -273,7 +273,7 @@ logging: layout: kind: pattern loggers: - - context: your-plugin + - context: plugins.myPlugin appenders: [file] ``` #### logging.json @@ -282,10 +282,10 @@ the output format with [layouts](#layouts). #### logging.quiet Suppresses all logging output other than error messages. With new logging, config can be achieved -with adjusting minimum required [logging level](#log-level) +with adjusting minimum required [logging level](#log-level). ```yaml loggers: - - context: my-plugin + - context: plugins.myPlugin appenders: [console] level: error # or for all output diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 927171438ae9..c46fcfbc6dbd 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -23,6 +23,7 @@ import { SavedObjectsErrorHelpers } from './errors'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { DocumentMigrator } from '../../migrations/core/document_migrator'; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -115,6 +116,7 @@ describe('SavedObjectsRepository', () => { const createType = type => ({ name: type, mappings: { properties: mappings.properties[type].properties }, + migrations: { '1.1.1': doc => doc }, }); const registry = new SavedObjectTypeRegistry(); @@ -144,6 +146,13 @@ describe('SavedObjectsRepository', () => { namespaceType: 'agnostic', }); + const documentMigrator = new DocumentMigrator({ + typeRegistry: registry, + kibanaVersion: '2.0.0', + log: {}, + validateDoc: jest.fn(), + }); + const getMockGetResponse = ({ type, id, references, namespace }) => ({ // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these found: true, @@ -207,7 +216,7 @@ describe('SavedObjectsRepository', () => { beforeEach(() => { callAdminCluster = jest.fn(); migrator = { - migrateDocument: jest.fn(doc => doc), + migrateDocument: jest.fn().mockImplementation(documentMigrator.migrate), runMigrations: async () => ({ status: 'skipped' }), }; @@ -424,9 +433,17 @@ describe('SavedObjectsRepository', () => { const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id }) => ({ + items: objects.map(({ type, id, attributes, references, migrationVersion }) => ({ create: { _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + references, + ...mockTimestampFields, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, ...mockVersionProps, }, })), @@ -474,7 +491,7 @@ describe('SavedObjectsRepository', () => { const expectSuccessResult = obj => ({ ...obj, - migrationVersion: undefined, + migrationVersion: { [obj.type]: '1.1.1' }, version: mockVersion, ...mockTimestampFields, }); @@ -619,13 +636,16 @@ describe('SavedObjectsRepository', () => { }; const bulkCreateError = async (obj, esError, expectedError) => { - const objects = [obj1, obj, obj2]; - const response = getMockBulkCreateResponse(objects); + let response; if (esError) { + response = getMockBulkCreateResponse([obj1, obj, obj2]); response.items[1].create = { error: esError }; + } else { + response = getMockBulkCreateResponse([obj1, obj2]); } callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const objects = [obj1, obj, obj2]; const result = await savedObjectsRepository.bulkCreate(objects); expectClusterCalls('bulk'); const objCall = esError ? expectObjArgs(obj) : []; @@ -781,7 +801,7 @@ describe('SavedObjectsRepository', () => { id: 'three', }; const objects = [obj1, obj, obj2]; - const response = getMockBulkCreateResponse(objects); + const response = getMockBulkCreateResponse([obj1, obj2]); callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) const result = await savedObjectsRepository.bulkCreate(objects); expect(callAdminCluster).toHaveBeenCalledTimes(1); @@ -789,6 +809,32 @@ describe('SavedObjectsRepository', () => { saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], }); }); + + it(`a deserialized saved object`, async () => { + // Test for fix to https://github.com/elastic/kibana/issues/65088 where + // we returned raw ID's when an object without an id was created. + const namespace = 'myspace'; + const response = getMockBulkCreateResponse([obj1, obj2], namespace); + callAdminCluster.mockResolvedValueOnce(response); // this._writeToCluster('bulk', ...) + + // Bulk create one object with id unspecified, and one with id specified + const result = await savedObjectsRepository.bulkCreate([{ ...obj1, id: undefined }, obj2], { + namespace, + }); + + // Assert that both raw docs from the ES response are deserialized + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(1, { + ...response.items[0].create, + _id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/), + }); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, response.items[1].create); + + // Assert that ID's are deserialized to remove the type and namespace + expect(result.saved_objects[0].id).toEqual( + expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) + ); + expect(result.saved_objects[1].id).toEqual(obj2.id); + }); }); }); @@ -1604,6 +1650,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, attributes, references, + migrationVersion: { [type]: '1.1.1' }, }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index bc8ad2cdb005..61027130e0eb 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -18,6 +18,7 @@ */ import { omit } from 'lodash'; +import uuid from 'uuid'; import { retryCallCluster } from '../../../elasticsearch/retry_call_cluster'; import { APICaller } from '../../../elasticsearch/'; @@ -299,6 +300,8 @@ export class SavedObjectsRepository { const requiresNamespacesCheck = method === 'index' && this._registry.isMultiNamespace(object.type); + if (object.id == null) object.id = uuid.v1(); + return { tag: 'Right' as 'Right', value: { @@ -404,35 +407,25 @@ export class SavedObjectsRepository { } const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; - const response = bulkResponse.items[esRequestIndex]; - const { - error, - _id: responseId, - _seq_no: seqNo, - _primary_term: primaryTerm, - } = Object.values(response)[0] as any; - - const { - _source: { type, [type]: attributes, references = [], namespaces }, - } = rawMigratedDoc; - - const id = requestedId || responseId; + const { error, ...rawResponse } = Object.values( + bulkResponse.items[esRequestIndex] + )[0] as any; + if (error) { return { - id, - type, - error: getBulkOperationError(error, type, id), + id: requestedId, + type: rawMigratedDoc._source.type, + error: getBulkOperationError(error, rawMigratedDoc._source.type, requestedId), }; } - return { - id, - type, - ...(namespaces && { namespaces }), - updated_at: time, - version: encodeVersion(seqNo, primaryTerm), - attributes, - references, - }; + + // When method == 'index' the bulkResponse doesn't include the indexed + // _source so we return rawMigratedDoc but have to spread the latest + // _seq_no and _primary_term values from the rawResponse. + return this._serializer.rawToSavedObject({ + ...rawMigratedDoc, + ...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term }, + }); }), }; } diff --git a/src/dev/build/tasks/build_kibana_platform_plugins.js b/src/dev/build/tasks/build_kibana_platform_plugins.js index 28d6b49f9e89..153a3120f896 100644 --- a/src/dev/build/tasks/build_kibana_platform_plugins.js +++ b/src/dev/build/tasks/build_kibana_platform_plugins.js @@ -39,11 +39,10 @@ export const BuildKibanaPlatformPluginsTask = { }); const reporter = CiStatsReporter.fromEnv(log); - const reportStatsName = build.isOss() ? 'oss distributable' : 'default distributable'; await runOptimizer(optimizerConfig) .pipe( - reportOptimizerStats(reporter, reportStatsName), + reportOptimizerStats(reporter, optimizerConfig), logOptimizerState(log, optimizerConfig) ) .toPromise(); diff --git a/src/dev/build/tasks/create_archives_task.js b/src/dev/build/tasks/create_archives_task.js index 06be1bd0bd14..541b9551dbc9 100644 --- a/src/dev/build/tasks/create_archives_task.js +++ b/src/dev/build/tasks/create_archives_task.js @@ -17,13 +17,22 @@ * under the License. */ -import path from 'path'; +import Path from 'path'; +import Fs from 'fs'; +import { promisify } from 'util'; + +import { CiStatsReporter } from '@kbn/dev-utils'; + import { mkdirp, compress } from '../lib'; +const asyncStat = promisify(Fs.stat); + export const CreateArchivesTask = { description: 'Creating the archives for each platform', async run(config, log, build) { + const archives = []; + // archive one at a time, parallel causes OOM sometimes for (const platform of config.getTargetPlatforms()) { const source = build.resolvePathForPlatform(platform, '.'); @@ -31,10 +40,15 @@ export const CreateArchivesTask = { log.info('archiving', source, 'to', destination); - await mkdirp(path.dirname(destination)); + await mkdirp(Path.dirname(destination)); - switch (path.extname(destination)) { + switch (Path.extname(destination)) { case '.zip': + archives.push({ + format: 'zip', + path: destination, + }); + await compress( 'zip', { @@ -51,6 +65,11 @@ export const CreateArchivesTask = { break; case '.gz': + archives.push({ + format: 'tar', + path: destination, + }); + await compress( 'tar', { @@ -71,5 +90,20 @@ export const CreateArchivesTask = { throw new Error(`Unexpected extension for archive destination: ${destination}`); } } + + const reporter = CiStatsReporter.fromEnv(log); + if (reporter.isEnabled()) { + await reporter.metrics( + await Promise.all( + archives.map(async ({ format, path }) => { + return { + group: `${build.isOss() ? 'oss ' : ''}distributable size`, + id: format, + value: (await asyncStat(path)).size, + }; + }) + ) + ); + } }, }; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/tabs/utils.ts b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/tabs/utils.ts index bdb1436c37ef..83335a6fabfe 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/tabs/utils.ts +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/tabs/utils.ts @@ -96,18 +96,21 @@ export function getTabs( tabs.push({ name: getTitle('indexed', filteredCount, totalCount), id: TAB_INDEXED_FIELDS, + 'data-test-subj': 'tab-indexedFields', }); if (indexPatternListProvider.areScriptedFieldsEnabled(indexPattern)) { tabs.push({ name: getTitle('scripted', filteredCount, totalCount), id: TAB_SCRIPTED_FIELDS, + 'data-test-subj': 'tab-scriptedFields', }); } tabs.push({ name: getTitle('sourceFilters', filteredCount, totalCount), id: TAB_SOURCE_FILTERS, + 'data-test-subj': 'tab-sourceFilters', }); return tabs; diff --git a/src/legacy/core_plugins/region_map/index.ts b/src/legacy/core_plugins/region_map/index.ts deleted file mode 100644 index 8c059314786b..000000000000 --- a/src/legacy/core_plugins/region_map/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import { Legacy } from 'kibana'; - -import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; - -const regionMapPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - id: 'region_map', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - hacks: [resolve(__dirname, 'public/legacy')], - injectDefaultVars(server) { - const { regionmap } = server.config().get('map'); - - return { - regionmap, - }; - }, - }, - init: (server: Legacy.Server) => ({}), - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - } as Legacy.PluginSpecOptions); - -// eslint-disable-next-line import/no-default-export -export default regionMapPluginInitializer; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index 1bc85fa110ca..698c124d2d80 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -301,7 +301,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` >
@@ -995,7 +995,7 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] >
diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx index 8bf205b8cb50..955d5244ce19 100644 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/application/dashboard_empty_screen.tsx @@ -50,8 +50,8 @@ export function DashboardEmptyScreen({ }: DashboardEmptyScreenProps) { const IS_DARK_THEME = uiSettings.get('theme:darkMode'); const emptyStateGraphicURL = IS_DARK_THEME - ? '/plugins/kibana/home/assets/welcome_graphic_dark_2x.png' - : '/plugins/kibana/home/assets/welcome_graphic_light_2x.png'; + ? '/plugins/home/assets/welcome_graphic_dark_2x.png' + : '/plugins/home/assets/welcome_graphic_light_2x.png'; const linkToVisualizeParagraph = (

) => obj.get('title').toLowerCase() === title.toLowerCase() ); } - -export function getRoutes() { - return { - edit: '/management/kibana/index_patterns/{{id}}', - addField: '/management/kibana/index_patterns/{{id}}/create-field', - indexedFields: '/management/kibana/index_patterns/{{id}}?_a=(tab:indexedFields)', - scriptedFields: '/management/kibana/index_patterns/{{id}}?_a=(tab:scriptedFields)', - sourceFilters: '/management/kibana/index_patterns/{{id}}?_a=(tab:sourceFilters)', - }; -} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index cb1e1d2bd0ef..ee56ad60441f 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -912,14 +912,6 @@ export class IndexPattern implements IIndexPattern { // (undocumented) removeScriptedField(field: IFieldType): Promise; // (undocumented) - get routes(): { - edit: string; - addField: string; - indexedFields: string; - scriptedFields: string; - sourceFilters: string; - }; - // (undocumented) save(saveAttempts?: number): Promise; // (undocumented) timeFieldName: string | undefined; @@ -1021,7 +1013,6 @@ export const indexPatterns: { validate: typeof validateIndexPattern; getFromSavedObject: typeof getFromSavedObject; flattenHitWrapper: typeof flattenHitWrapper; - getRoutes: typeof getRoutes; formatHitProvider: typeof formatHitProvider; }; @@ -1812,27 +1803,26 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:237:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:376:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/legacy/fetch_soon.test.ts b/src/plugins/data/public/search/legacy/fetch_soon.test.ts index b2e17798ccc9..6c0467e3297e 100644 --- a/src/plugins/data/public/search/legacy/fetch_soon.test.ts +++ b/src/plugins/data/public/search/legacy/fetch_soon.test.ts @@ -58,7 +58,7 @@ describe('fetchSoon', () => { (callClient as jest.Mock).mockClear(); }); - test('should delay by 0ms if config is set to not batch searches', () => { + test('should execute asap if config is set to not batch searches', () => { const config = getConfigStub({ 'courier:batchSearches': false, }); @@ -67,8 +67,6 @@ describe('fetchSoon', () => { fetchSoon(request, options, { config } as FetchHandlers); - expect(callClient).not.toBeCalled(); - jest.advanceTimersByTime(0); expect(callClient).toBeCalled(); }); diff --git a/src/plugins/data/public/search/legacy/fetch_soon.ts b/src/plugins/data/public/search/legacy/fetch_soon.ts index 18fa410a5bef..83617d394fe9 100644 --- a/src/plugins/data/public/search/legacy/fetch_soon.ts +++ b/src/plugins/data/public/search/legacy/fetch_soon.ts @@ -67,6 +67,10 @@ async function delayedFetch( fetchHandlers: FetchHandlers, ms: number ) { + if (ms === 0) { + return callClient([request], [options], fetchHandlers)[0]; + } + const i = requestsToFetch.length; requestsToFetch = [...requestsToFetch, request]; requestOptions = [...requestOptions, options]; diff --git a/src/plugins/discover/public/application/_discover.scss b/src/plugins/discover/public/application/_discover.scss index 8eaa66cf5862..590dbebdf4cf 100644 --- a/src/plugins/discover/public/application/_discover.scss +++ b/src/plugins/discover/public/application/_discover.scss @@ -38,17 +38,7 @@ discover-app { } .dscResultCount { - text-align: center; padding-top: $euiSizeXS; - padding-left: $euiSizeM; - - .dscResultHits { - padding-left: $euiSizeXS; - } - - > .kuiLink { - padding-left: $euiSizeM; - } } .dscTimechart__header { diff --git a/src/plugins/discover/public/application/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html index b4db89b9275b..a0f98ea38ef7 100644 --- a/src/plugins/discover/public/application/angular/discover.html +++ b/src/plugins/discover/public/application/angular/discover.html @@ -89,24 +89,12 @@

{{screenTitle}}

-
- {{(hits || 0) | number:0}} - - -
+ +
; + + beforeAll(() => { + props = { + onResetQuery: jest.fn(), + showResetButton: true, + hits: 2, + }; + }); + + it('HitsCounter renders a button by providing the showResetButton property', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'resetSavedSearch').length).toBe(1); + }); + + it('HitsCounter not renders a button when the showResetButton property is false', () => { + component = mountWithIntl( + + ); + expect(findTestSubject(component, 'resetSavedSearch').length).toBe(0); + }); + + it('expect to render the number of hits', function() { + component = mountWithIntl(); + const hits = findTestSubject(component, 'discoverQueryHits'); + expect(hits.text()).toBe('2'); + }); + + it('expect to render 1,899 hits if 1899 hits given', function() { + component = mountWithIntl( + + ); + const hits = findTestSubject(component, 'discoverQueryHits'); + expect(hits.text()).toBe('1,899'); + }); + + it('should reset query', function() { + component = mountWithIntl(); + findTestSubject(component, 'resetSavedSearch').simulate('click'); + expect(props.onResetQuery).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter.tsx b/src/plugins/discover/public/application/components/hits_counter/hits_counter.tsx new file mode 100644 index 000000000000..1d2cd12877b1 --- /dev/null +++ b/src/plugins/discover/public/application/components/hits_counter/hits_counter.tsx @@ -0,0 +1,83 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { formatNumWithCommas } from '../../helpers'; + +export interface HitsCounterProps { + /** + * the number of query hits + */ + hits: number; + /** + * displays the reset button + */ + showResetButton: boolean; + /** + * resets the query + */ + onResetQuery: () => void; +} + +export function HitsCounter({ hits, showResetButton, onResetQuery }: HitsCounterProps) { + return ( + + + + + {formatNumWithCommas(hits)}{' '} + + + + {showResetButton && ( + + + + + + )} + + + ); +} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts b/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts similarity index 60% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts rename to src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts index 99f54277be5d..8d45e28370ca 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/index.ts +++ b/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts @@ -16,24 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +import { HitsCounter } from './hits_counter'; -import { Legacy } from 'kibana'; - -// eslint-disable-next-line import/no-default-export -export default function(kibana: any) { - return new kibana.Plugin({ - require: ['kibana'], - uiExports: { - app: { - title: 'Embeddable Explorer', - order: 1, - main: 'plugins/kbn_tp_embeddable_explorer/np_ready/public/legacy', - }, - }, - init(server: Legacy.Server) { - server.injectUiAppVars('kbn_tp_embeddable_explorer', async () => - server.getInjectedUiAppVars('kibana') - ); - }, - }); +export function createHitsCounterDirective(reactDirective: any) { + return reactDirective(HitsCounter, [ + ['hits', { watchDepth: 'reference' }], + ['showResetButton', { watchDepth: 'reference' }], + ['onResetQuery', { watchDepth: 'reference' }], + ]); } diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts b/src/plugins/discover/public/application/components/hits_counter/index.ts similarity index 87% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts rename to src/plugins/discover/public/application/components/hits_counter/index.ts index a4bc3cf17026..58e7a9eda7f5 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/initialize.ts +++ b/src/plugins/discover/public/application/components/hits_counter/index.ts @@ -17,4 +17,5 @@ * under the License. */ -import './np_ready/public/legacy'; +export { HitsCounter } from './hits_counter'; +export { createHitsCounterDirective } from './hits_counter_directive'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts b/src/plugins/discover/public/application/helpers/format_number_with_commas.ts similarity index 76% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts rename to src/plugins/discover/public/application/helpers/format_number_with_commas.ts index dd25bebf8992..01a010d823d5 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/embeddable_api.ts +++ b/src/plugins/discover/public/application/helpers/format_number_with_commas.ts @@ -17,6 +17,11 @@ * under the License. */ -export * from '../../../../../../../src/plugins/embeddable/public'; -export * from '../../../../../../../src/plugins/embeddable/public/lib/test_samples'; -export { HELLO_WORLD_EMBEDDABLE } from '../../../../../../../examples/embeddable_examples/public'; +const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g; + +/** + * Converts a number to a string and adds commas + * as thousands separators + */ +export const formatNumWithCommas = (input: number) => + String(input).replace(COMMA_SEPARATOR_RE, '$1,'); diff --git a/src/plugins/discover/public/application/helpers/index.ts b/src/plugins/discover/public/application/helpers/index.ts index 7196c96989e9..3555d24924e8 100644 --- a/src/plugins/discover/public/application/helpers/index.ts +++ b/src/plugins/discover/public/application/helpers/index.ts @@ -18,3 +18,4 @@ */ export { shortenDottedString } from './shorten_dotted_string'; +export { formatNumWithCommas } from './format_number_with_commas'; diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index e7813c43383f..8c3f4f030688 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -57,6 +57,7 @@ import { createTopNavHelper, } from '../../kibana_legacy/public'; import { createDiscoverSidebarDirective } from './application/components/sidebar'; +import { createHitsCounterDirective } from '././application/components/hits_counter'; import { DiscoverStartPlugins } from './plugin'; /** @@ -151,6 +152,7 @@ export function initializeInnerAngularModule( .directive('fixedScroll', FixedScrollProvider) .directive('renderComplete', createRenderCompleteDirective) .directive('discoverSidebar', createDiscoverSidebarDirective) + .directive('hitsCounter', createHitsCounterDirective) .service('debounce', ['$timeout', DebounceProviderTimeout]); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 35a10ed848e8..bb2eb52f9df7 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -62,16 +62,34 @@ function renderNotifications( notifications: Array>, embeddable: IEmbeddable ) { - return notifications.map(notification => ( - notification.execute({ embeddable })} - > - {notification.getDisplayName({ embeddable })} - - )); + return notifications.map(notification => { + const context = { embeddable }; + + let badge = ( + notification.execute(context)} + > + {notification.getDisplayName(context)} + + ); + + if (notification.getDisplayNameTooltip) { + const tooltip = notification.getDisplayNameTooltip(context); + + if (tooltip) { + badge = ( + + {badge} + + ); + } + } + + return badge; + }); } function renderTooltip(description: string) { diff --git a/src/plugins/home/public/application/components/sample_data/index.tsx b/src/plugins/home/public/application/components/sample_data/index.tsx index 381aa49c30d5..2a51b48b0846 100644 --- a/src/plugins/home/public/application/components/sample_data/index.tsx +++ b/src/plugins/home/public/application/components/sample_data/index.tsx @@ -42,7 +42,7 @@ interface Props { export function SampleDataCard({ urlBasePath, onDecline, onConfirm }: Props) { return ( } description={ diff --git a/src/legacy/core_plugins/kibana/public/home/assets/illustration_elastic_heart.png b/src/plugins/home/public/assets/illustration_elastic_heart.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/illustration_elastic_heart.png rename to src/plugins/home/public/assets/illustration_elastic_heart.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/ecommerce/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/logs/dashboard.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png rename to src/plugins/home/public/assets/sample_data_resources/logs/dashboard.png diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/logs/dashboard_dark.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard_dark.png rename to src/plugins/home/public/assets/sample_data_resources/logs/dashboard_dark.png diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png b/src/plugins/home/public/assets/welcome_graphic_dark_2x.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_dark_2x.png rename to src/plugins/home/public/assets/welcome_graphic_dark_2x.png diff --git a/src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png b/src/plugins/home/public/assets/welcome_graphic_light_2x.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/assets/welcome_graphic_light_2x.png rename to src/plugins/home/public/assets/welcome_graphic_light_2x.png diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts index 3e16187c4434..b0cc2e2db3cc 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts @@ -36,8 +36,8 @@ export const ecommerceSpecProvider = function(): SampleDatasetSchema { id: 'ecommerce', name: ecommerceName, description: ecommerceDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/ecommerce/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/ecommerce/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard_dark.png', overviewDashboard: '722b74f0-b882-11e8-a6d9-e546fe2bba5f', appLinks: initialAppLinks, defaultIndex: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts index d63ea8f7fb49..fc3cb6094b5e 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts @@ -36,8 +36,8 @@ export const flightsSpecProvider = function(): SampleDatasetSchema { id: 'flights', name: flightsName, description: flightsDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/flights/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/flights/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard_dark.png', overviewDashboard: '7adfa750-4c81-11e8-b3d7-01146121b73d', appLinks: initialAppLinks, defaultIndex: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index bb6e2982f59a..d8f205dff24e 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -36,8 +36,8 @@ export const logsSpecProvider = function(): SampleDatasetSchema { id: 'logs', name: logsName, description: logsDescription, - previewImagePath: '/plugins/kibana/home/sample_data_resources/logs/dashboard.png', - darkPreviewImagePath: '/plugins/kibana/home/sample_data_resources/logs/dashboard_dark.png', + previewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard.png', + darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard_dark.png', overviewDashboard: 'edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b', appLinks: initialAppLinks, defaultIndex: '90943e30-9a47-11e8-b64d-95841ca0b247', diff --git a/src/plugins/maps_legacy/config.ts b/src/plugins/maps_legacy/config.ts index 13a0ad6b393a..67e46d227058 100644 --- a/src/plugins/maps_legacy/config.ts +++ b/src/plugins/maps_legacy/config.ts @@ -19,31 +19,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { configSchema as tilemapSchema } from '../tile_map/config'; - -// TODO: Pull this portion from region_map -export const regionmapSchema = schema.object({ - includeElasticMapsService: schema.boolean({ defaultValue: true }), - layers: schema.arrayOf( - schema.object({ - url: schema.string(), - format: schema.object({ - type: schema.string({ defaultValue: 'geojson' }), - }), - meta: schema.object({ - feature_collection_path: schema.string({ defaultValue: 'data' }), - }), - attribution: schema.string(), - name: schema.string(), - fields: schema.arrayOf( - schema.object({ - name: schema.string(), - description: schema.string(), - }) - ), - }), - { defaultValue: [] } - ), -}); +import { configSchema as regionmapSchema } from '../region_map/config'; export const configSchema = schema.object({ includeElasticMapsService: schema.boolean({ defaultValue: true }), diff --git a/src/plugins/maps_legacy/public/index.ts b/src/plugins/maps_legacy/public/index.ts index 3fe377fbdc41..a7f542790933 100644 --- a/src/plugins/maps_legacy/public/index.ts +++ b/src/plugins/maps_legacy/public/index.ts @@ -45,6 +45,7 @@ import { import { mapTooltipProvider } from './tooltip_provider'; export interface MapsLegacyConfigType { + regionmap: any; emsTileLayerId: string; includeElasticMapsService: boolean; proxyElasticMapsServiceInMaps: boolean; diff --git a/src/legacy/core_plugins/region_map/public/legacy.ts b/src/plugins/region_map/config.ts similarity index 51% rename from src/legacy/core_plugins/region_map/public/legacy.ts rename to src/plugins/region_map/config.ts index 4bbd839331e5..a721a76ca0a8 100644 --- a/src/legacy/core_plugins/region_map/public/legacy.ts +++ b/src/plugins/region_map/config.ts @@ -17,19 +17,30 @@ * under the License. */ -import { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; +import { schema, TypeOf } from '@kbn/config-schema'; -import { RegionMapPluginSetupDependencies } from './plugin'; -import { plugin } from '.'; +export const configSchema = schema.object({ + includeElasticMapsService: schema.boolean({ defaultValue: true }), + layers: schema.arrayOf( + schema.object({ + url: schema.string(), + format: schema.object({ + type: schema.string({ defaultValue: 'geojson' }), + }), + meta: schema.object({ + feature_collection_path: schema.string({ defaultValue: 'data' }), + }), + attribution: schema.string(), + name: schema.string(), + fields: schema.arrayOf( + schema.object({ + name: schema.string(), + description: schema.string(), + }) + ), + }), + { defaultValue: [] } + ), +}); -const plugins: Readonly = { - expressions: npSetup.plugins.expressions, - visualizations: npSetup.plugins.visualizations, - mapsLegacy: npSetup.plugins.mapsLegacy, -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, plugins); -export const start = pluginInstance.start(npStart.core); +export type ConfigSchema = TypeOf; diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json new file mode 100644 index 000000000000..3a6f64e92bcb --- /dev/null +++ b/src/plugins/region_map/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "regionMap", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["map", "regionmap"], + "ui": true, + "server": true, + "requiredPlugins": [ + "visualizations", + "expressions", + "mapsLegacy", + "data" + ] +} diff --git a/src/legacy/core_plugins/region_map/package.json b/src/plugins/region_map/package.json similarity index 100% rename from src/legacy/core_plugins/region_map/package.json rename to src/plugins/region_map/package.json diff --git a/src/legacy/core_plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap b/src/plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap similarity index 100% rename from src/legacy/core_plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap rename to src/plugins/region_map/public/__snapshots__/region_map_fn.test.js.snap diff --git a/src/legacy/core_plugins/region_map/public/__tests__/aftercolorchange.png b/src/plugins/region_map/public/__tests__/aftercolorchange.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/aftercolorchange.png rename to src/plugins/region_map/public/__tests__/aftercolorchange.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterdatachange.png b/src/plugins/region_map/public/__tests__/afterdatachange.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterdatachange.png rename to src/plugins/region_map/public/__tests__/afterdatachange.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterdatachangeandresize.png b/src/plugins/region_map/public/__tests__/afterdatachangeandresize.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterdatachangeandresize.png rename to src/plugins/region_map/public/__tests__/afterdatachangeandresize.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/afterresize.png b/src/plugins/region_map/public/__tests__/afterresize.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/afterresize.png rename to src/plugins/region_map/public/__tests__/afterresize.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/changestartup.png b/src/plugins/region_map/public/__tests__/changestartup.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/changestartup.png rename to src/plugins/region_map/public/__tests__/changestartup.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/initial.png b/src/plugins/region_map/public/__tests__/initial.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/initial.png rename to src/plugins/region_map/public/__tests__/initial.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/plugins/region_map/public/__tests__/region_map_visualization.js similarity index 92% rename from src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js rename to src/plugins/region_map/public/__tests__/region_map_visualization.js index 7271f39debb3..cefef98fae81 100644 --- a/src/legacy/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/plugins/region_map/public/__tests__/region_map_visualization.js @@ -25,17 +25,17 @@ import ChoroplethLayer from '../choropleth_layer'; import { ImageComparator } from 'test_utils/image_comparator'; import worldJson from './world.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_CATALOGUE from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; +import EMS_CATALOGUE from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_manifest.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_FILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; +import EMS_FILES from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_files.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_TILES from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; +import EMS_TILES from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_tiles.json'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; +import EMS_STYLE_ROAD_MAP_BRIGHT from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_bright'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; +import EMS_STYLE_ROAD_MAP_DESATURATED from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_desaturated'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import EMS_STYLE_DARK_MAP from '../../../../../plugins/maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; +import EMS_STYLE_DARK_MAP from '../../../maps_legacy/public/__tests__/map/ems_mocks/sample_style_dark'; import initialPng from './initial.png'; import toiso3Png from './toiso3.png'; @@ -48,14 +48,14 @@ import changestartupPng from './changestartup.png'; import { createRegionMapVisualization } from '../region_map_visualization'; import { createRegionMapTypeDefinition } from '../region_map_type'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ExprVis } from '../../../../../plugins/visualizations/public/expressions/vis'; +import { ExprVis } from '../../../visualizations/public/expressions/vis'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { BaseVisType } from '../../../../../plugins/visualizations/public/vis_types/base_vis_type'; +import { BaseVisType } from '../../../visualizations/public/vis_types/base_vis_type'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { setInjectedVarFunc } from '../../../../../plugins/maps_legacy/public/kibana_services'; +import { setInjectedVarFunc } from '../../../maps_legacy/public/kibana_services'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceSettings } from '../../../../../plugins/maps_legacy/public/map/service_settings'; -import { getBaseMapsVis } from '../../../../../plugins/maps_legacy/public'; +import { ServiceSettings } from '../../../maps_legacy/public/map/service_settings'; +import { getBaseMapsVis } from '../../../maps_legacy/public'; const THRESHOLD = 0.45; const PIXEL_DIFF = 96; diff --git a/src/legacy/core_plugins/region_map/public/__tests__/toiso3.png b/src/plugins/region_map/public/__tests__/toiso3.png similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/toiso3.png rename to src/plugins/region_map/public/__tests__/toiso3.png diff --git a/src/legacy/core_plugins/region_map/public/__tests__/world.json b/src/plugins/region_map/public/__tests__/world.json similarity index 100% rename from src/legacy/core_plugins/region_map/public/__tests__/world.json rename to src/plugins/region_map/public/__tests__/world.json diff --git a/src/legacy/core_plugins/region_map/public/choropleth_layer.js b/src/plugins/region_map/public/choropleth_layer.js similarity index 98% rename from src/legacy/core_plugins/region_map/public/choropleth_layer.js rename to src/plugins/region_map/public/choropleth_layer.js index f8c48958a1b9..ddaf2db257fb 100644 --- a/src/legacy/core_plugins/region_map/public/choropleth_layer.js +++ b/src/plugins/region_map/public/choropleth_layer.js @@ -22,9 +22,9 @@ import _ from 'lodash'; import d3 from 'd3'; import { i18n } from '@kbn/i18n'; import * as topojson from 'topojson-client'; -import { toastNotifications } from 'ui/notify'; -import { colorUtil, KibanaMapLayer } from '../../../../plugins/maps_legacy/public'; -import { truncatedColorMaps } from '../../../../plugins/charts/public'; +import { getNotifications } from './kibana_services'; +import { colorUtil, KibanaMapLayer } from '../../maps_legacy/public'; +import { truncatedColorMaps } from '../../charts/public'; const EMPTY_STYLE = { weight: 1, @@ -182,7 +182,7 @@ CORS configuration of the server permits requests from the Kibana application on ); } - toastNotifications.addDanger({ + getNotifications().toasts.addDanger({ title: i18n.translate( 'regionMap.choroplethLayer.downloadingVectorDataErrorMessageTitle', { diff --git a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx b/src/plugins/region_map/public/components/region_map_options.tsx similarity index 95% rename from src/legacy/core_plugins/region_map/public/components/region_map_options.tsx rename to src/plugins/region_map/public/components/region_map_options.tsx index 5604067433f1..9a6987b98153 100644 --- a/src/legacy/core_plugins/region_map/public/components/region_map_options.tsx +++ b/src/plugins/region_map/public/components/region_map_options.tsx @@ -22,17 +22,9 @@ import { EuiIcon, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elast import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { VisOptionsProps } from 'src/plugins/vis_default_editor/public'; -import { - FileLayerField, - VectorLayer, - IServiceSettings, -} from '../../../../../plugins/maps_legacy/public'; -import { - NumberInputOption, - SelectOption, - SwitchOption, -} from '../../../../../plugins/charts/public'; -import { RegionMapVisParams, WmsOptions } from '../../../../../plugins/maps_legacy/public'; +import { FileLayerField, VectorLayer, IServiceSettings } from '../../../maps_legacy/public'; +import { NumberInputOption, SelectOption, SwitchOption } from '../../../charts/public'; +import { RegionMapVisParams, WmsOptions } from '../../../maps_legacy/public'; const mapLayerForOption = ({ layerId, name }: VectorLayer) => ({ text: name, diff --git a/src/legacy/core_plugins/region_map/public/index.ts b/src/plugins/region_map/public/index.ts similarity index 86% rename from src/legacy/core_plugins/region_map/public/index.ts rename to src/plugins/region_map/public/index.ts index a29f5aa24702..3f920ad16683 100644 --- a/src/legacy/core_plugins/region_map/public/index.ts +++ b/src/plugins/region_map/public/index.ts @@ -17,9 +17,14 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../core/public'; +import { PluginInitializerContext } from 'kibana/public'; import { RegionMapPlugin as Plugin } from './plugin'; +export interface RegionMapsConfigType { + includeElasticMapsService: boolean; + layers: any[]; +} + export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } diff --git a/src/plugins/region_map/public/kibana_services.ts b/src/plugins/region_map/public/kibana_services.ts new file mode 100644 index 000000000000..1ef58c69c5be --- /dev/null +++ b/src/plugins/region_map/public/kibana_services.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { NotificationsStart } from 'kibana/public'; +import { createGetterSetter } from '../../kibana_utils/public'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getFormatService, setFormatService] = createGetterSetter< + DataPublicPluginStart['fieldFormats'] +>('data.fieldFormats'); + +export const [getNotifications, setNotifications] = createGetterSetter( + 'Notifications' +); diff --git a/src/legacy/core_plugins/region_map/public/plugin.ts b/src/plugins/region_map/public/plugin.ts similarity index 56% rename from src/legacy/core_plugins/region_map/public/plugin.ts rename to src/plugins/region_map/public/plugin.ts index 08a73517dc13..09a13fbe9774 100644 --- a/src/legacy/core_plugins/region_map/public/plugin.ts +++ b/src/plugins/region_map/public/plugin.ts @@ -22,18 +22,19 @@ import { Plugin, PluginInitializerContext, IUiSettingsClient, -} from '../../../../core/public'; -import { Plugin as ExpressionsPublicPlugin } from '../../../../plugins/expressions/public'; -import { VisualizationsSetup } from '../../../../plugins/visualizations/public'; + NotificationsStart, +} from 'kibana/public'; +import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; +import { VisualizationsSetup } from '../../visualizations/public'; // @ts-ignore import { createRegionMapFn } from './region_map_fn'; // @ts-ignore import { createRegionMapTypeDefinition } from './region_map_type'; -import { - getBaseMapsVis, - IServiceSettings, - MapsLegacyPluginSetup, -} from '../../../../plugins/maps_legacy/public'; +import { getBaseMapsVis, IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; +import { setFormatService, setNotifications } from './kibana_services'; +import { DataPublicPluginStart } from '../../data/public'; +import { RegionMapsConfigType } from './index'; +import { ConfigSchema } from '../../maps_legacy/config'; /** @private */ interface RegionMapVisualizationDependencies { @@ -50,27 +51,46 @@ export interface RegionMapPluginSetupDependencies { mapsLegacy: MapsLegacyPluginSetup; } +/** @internal */ +export interface RegionMapPluginStartDependencies { + data: DataPublicPluginStart; + notifications: NotificationsStart; +} + /** @internal */ export interface RegionMapsConfig { includeElasticMapsService: boolean; layers: any[]; } +export interface RegionMapPluginSetup { + config: any; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface RegionMapPluginStart {} + /** @internal */ -export class RegionMapPlugin implements Plugin, void> { - initializerContext: PluginInitializerContext; +export class RegionMapPlugin implements Plugin { + readonly _initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; + this._initializerContext = initializerContext; } public async setup( core: CoreSetup, { expressions, visualizations, mapsLegacy }: RegionMapPluginSetupDependencies ) { + const config = { + ...this._initializerContext.config.get(), + // The maps legacy plugin updates the regionmap config directly in service_settings, + // future work on how configurations across the different plugins are organized would + // ideally constrain regionmap config updates to occur only from this plugin + ...mapsLegacy.config.regionmap, + }; const visualizationDependencies: Readonly = { uiSettings: core.uiSettings, - regionmapsConfig: core.injectedMetadata.getInjectedVar('regionmap') as RegionMapsConfig, + regionmapsConfig: config as RegionMapsConfig, serviceSettings: mapsLegacy.serviceSettings, BaseMapsVisualization: getBaseMapsVis(core, mapsLegacy.serviceSettings), }; @@ -80,9 +100,15 @@ export class RegionMapPlugin implements Plugin, void> { visualizations.createBaseVisualization( createRegionMapTypeDefinition(visualizationDependencies) ); + + return { + config, + }; } - public start(core: CoreStart) { - // nothing to do here yet + // @ts-ignore + public start(core: CoreStart, { data }: RegionMapPluginStartDependencies) { + setFormatService(data.fieldFormats); + setNotifications(core.notifications); } } diff --git a/src/legacy/core_plugins/region_map/public/region_map_fn.js b/src/plugins/region_map/public/region_map_fn.js similarity index 100% rename from src/legacy/core_plugins/region_map/public/region_map_fn.js rename to src/plugins/region_map/public/region_map_fn.js diff --git a/src/legacy/core_plugins/region_map/public/region_map_fn.test.js b/src/plugins/region_map/public/region_map_fn.test.js similarity index 92% rename from src/legacy/core_plugins/region_map/public/region_map_fn.test.js rename to src/plugins/region_map/public/region_map_fn.test.js index 07b4e33b85e2..684cc5e897df 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_fn.test.js +++ b/src/plugins/region_map/public/region_map_fn.test.js @@ -18,11 +18,9 @@ */ // eslint-disable-next-line -import { functionWrapper } from '../../../../plugins/expressions/common/expression_functions/specs/tests/utils'; +import { functionWrapper } from '../../expressions/common/expression_functions/specs/tests/utils'; import { createRegionMapFn } from './region_map_fn'; -jest.mock('ui/new_platform'); - describe('interpreter/functions#regionmap', () => { const fn = functionWrapper(createRegionMapFn()); const context = { diff --git a/src/legacy/core_plugins/region_map/public/region_map_type.js b/src/plugins/region_map/public/region_map_type.js similarity index 95% rename from src/legacy/core_plugins/region_map/public/region_map_type.js rename to src/plugins/region_map/public/region_map_type.js index b7ed14ed3706..d29360a9589a 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_type.js +++ b/src/plugins/region_map/public/region_map_type.js @@ -21,9 +21,9 @@ import { i18n } from '@kbn/i18n'; import { mapToLayerWithId } from './util'; import { createRegionMapVisualization } from './region_map_visualization'; import { RegionMapOptions } from './components/region_map_options'; -import { truncatedColorSchemas } from '../../../../plugins/charts/public'; -import { Schemas } from '../../../../plugins/vis_default_editor/public'; -import { ORIGIN } from '../../../../plugins/maps_legacy/public'; +import { truncatedColorSchemas } from '../../charts/public'; +import { Schemas } from '../../vis_default_editor/public'; +import { ORIGIN } from '../../maps_legacy/public'; export function createRegionMapTypeDefinition(dependencies) { const { uiSettings, regionmapsConfig, serviceSettings } = dependencies; diff --git a/src/legacy/core_plugins/region_map/public/region_map_visualization.js b/src/plugins/region_map/public/region_map_visualization.js similarity index 93% rename from src/legacy/core_plugins/region_map/public/region_map_visualization.js rename to src/plugins/region_map/public/region_map_visualization.js index 5dbc1ecad277..ed6a3ed2c10c 100644 --- a/src/legacy/core_plugins/region_map/public/region_map_visualization.js +++ b/src/plugins/region_map/public/region_map_visualization.js @@ -19,11 +19,10 @@ import { i18n } from '@kbn/i18n'; import ChoroplethLayer from './choropleth_layer'; -import { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; -import { toastNotifications } from 'ui/notify'; -import { truncatedColorMaps } from '../../../../plugins/charts/public'; +import { getFormatService, getNotifications } from './kibana_services'; +import { truncatedColorMaps } from '../../charts/public'; import { tooltipFormatter } from './tooltip_formatter'; -import { mapTooltipProvider } from '../../../../plugins/maps_legacy/public'; +import { mapTooltipProvider } from '../../maps_legacy/public'; export function createRegionMapVisualization({ serviceSettings, @@ -75,7 +74,7 @@ export function createRegionMapVisualization({ results ); - const metricFieldFormatter = getFormat(this._params.metric.format); + const metricFieldFormatter = getFormatService().deserialize(this._params.metric.format); this._choroplethLayer.setMetrics(results, metricFieldFormatter, valueColumn.name); if (termColumn && valueColumn) { @@ -108,7 +107,7 @@ export function createRegionMapVisualization({ this._params.showAllShapes ); - const metricFieldFormatter = getFormat(this._params.metric.format); + const metricFieldFormatter = getFormatService().deserialize(this._params.metric.format); this._choroplethLayer.setJoinField(visParams.selectedJoinField.name); this._choroplethLayer.setColorRamp(truncatedColorMaps[visParams.colorSchema].value); @@ -177,7 +176,7 @@ export function createRegionMapVisualization({ const shouldShowWarning = this._params.isDisplayWarning && uiSettings.get('visualization:regionmap:showWarnings'); if (event.mismatches.length > 0 && shouldShowWarning) { - toastNotifications.addWarning({ + getNotifications().toasts.addWarning({ title: i18n.translate('regionMap.visualization.unableToShowMismatchesWarningTitle', { defaultMessage: 'Unable to show {mismatchesLength} {oneMismatch, plural, one {result} other {results}} on map', diff --git a/src/legacy/core_plugins/region_map/public/tooltip_formatter.js b/src/plugins/region_map/public/tooltip_formatter.js similarity index 100% rename from src/legacy/core_plugins/region_map/public/tooltip_formatter.js rename to src/plugins/region_map/public/tooltip_formatter.js diff --git a/src/legacy/core_plugins/region_map/public/util.ts b/src/plugins/region_map/public/util.ts similarity index 86% rename from src/legacy/core_plugins/region_map/public/util.ts rename to src/plugins/region_map/public/util.ts index b4e0dcd5f351..0160a32e8152 100644 --- a/src/legacy/core_plugins/region_map/public/util.ts +++ b/src/plugins/region_map/public/util.ts @@ -17,8 +17,8 @@ * under the License. */ -import { FileLayer, VectorLayer } from '../../../../plugins/maps_legacy/public'; -import { ORIGIN } from '../../../../plugins/maps_legacy/public'; +import { FileLayer, VectorLayer } from '../../maps_legacy/public'; +import { ORIGIN } from '../../maps_legacy/public'; export const mapToLayerWithId = (prefix: string, layer: FileLayer): VectorLayer => ({ ...layer, diff --git a/src/plugins/region_map/server/index.ts b/src/plugins/region_map/server/index.ts new file mode 100644 index 000000000000..e2c544d2d0ba --- /dev/null +++ b/src/plugins/region_map/server/index.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginConfigDescriptor } from 'kibana/server'; +import { configSchema, ConfigSchema } from '../config'; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + includeElasticMapsService: true, + layers: true, + }, + schema: configSchema, +}; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index e3504c7c5d30..aba1e22fe09e 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -48,6 +48,11 @@ export class ActionInternal return this.definition.getDisplayName(context); } + public getDisplayNameTooltip(context: Context): string { + if (!this.definition.getDisplayNameTooltip) return ''; + return this.definition.getDisplayNameTooltip(context); + } + public async isCompatible(context: Context): Promise { if (!this.definition.isCompatible) return true; return await this.definition.isCompatible(context); diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index f43b776e7465..57070f7673f6 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -50,6 +50,12 @@ export interface Presentable { */ getDisplayName(context: Context): string; + /** + * Returns tooltip text which should be displayed when user hovers this object. + * Should return empty string if tooltip should not be displayed. + */ + getDisplayNameTooltip(context: Context): string; + /** * This method should return a link if this item can be clicked on. The link * is used to navigate user if user middle-clicks it or Ctrl + clicks or diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js index ed17fceeda54..4aa8856836fc 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/markdown.js @@ -64,7 +64,7 @@ class MarkdownPanelConfigUi extends Component { const lessSrc = `#markdown-${model.id} { ${value} }`; - lessC.render(lessSrc, { compress: true }, (e, output) => { + lessC.render(lessSrc, { compress: true, javascriptEnabled: false }, (e, output) => { const parts = { markdown_less: value }; if (output) { parts.markdown_css = output.css; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js index 98890f746291..87e9734ea105 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/table/process_bucket.js @@ -21,7 +21,8 @@ import { buildProcessorFunction } from '../build_processor_function'; import { processors } from '../response_processors/table'; import { getLastValue } from '../../../../common/get_last_value'; import regression from 'regression'; -import { first, get, set } from 'lodash'; +import { first, get } from 'lodash'; +import { overwrite } from '../helpers'; import { getActiveSeries } from '../helpers/get_active_series'; export function processBucket(panel) { @@ -35,7 +36,7 @@ export function processBucket(panel) { const timeseries = { buckets: get(bucket, `${series.id}.buckets`), }; - set(bucket, series.id, { meta, timeseries }); + overwrite(bucket, series.id, { meta, timeseries }); } const processor = buildProcessorFunction(processors, bucket, panel, series); diff --git a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts index fa4427fbb8c1..1d565e69a801 100644 --- a/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts +++ b/src/plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -17,59 +17,65 @@ * under the License. */ -import Joi from 'joi'; -const stringOptionalNullable = Joi.string() - .allow('', null) - .optional(); -const stringRequired = Joi.string() - .allow('') - .required(); -const arrayNullable = Joi.array().allow(null); -const numberIntegerOptional = Joi.number() - .integer() - .optional(); -const numberIntegerRequired = Joi.number() - .integer() - .required(); -const numberOptional = Joi.number().optional(); -const queryObject = Joi.object({ - language: Joi.string().allow(''), - query: Joi.string().allow(''), +import { schema } from '@kbn/config-schema'; +import { TypeOptions } from '@kbn/config-schema/target/types/types'; + +const stringOptionalNullable = schema.maybe(schema.nullable(schema.string())); + +const stringRequired = schema.string(); + +const arrayNullable = schema.arrayOf(schema.nullable(schema.any())); + +const validateInteger: TypeOptions['validate'] = value => { + if (!Number.isInteger(value)) { + return `${value} is not an integer`; + } +}; +const numberIntegerOptional = schema.maybe(schema.number({ validate: validateInteger })); +const numberIntegerRequired = schema.number({ validate: validateInteger }); + +const numberOptional = schema.maybe(schema.number()); + +const queryObject = schema.object({ + language: schema.string(), + query: schema.string(), }); -const stringOrNumberOptionalNullable = Joi.alternatives([stringOptionalNullable, numberOptional]); -const numberOptionalOrEmptyString = Joi.alternatives(numberOptional, Joi.string().valid('')); +const stringOrNumberOptionalNullable = schema.nullable( + schema.oneOf([stringOptionalNullable, numberOptional]) +); +const numberOptionalOrEmptyString = schema.maybe( + schema.oneOf([numberOptional, schema.literal('')]) +); -const annotationsItems = Joi.object({ +const annotationsItems = schema.object({ color: stringOptionalNullable, fields: stringOptionalNullable, - hidden: Joi.boolean().optional(), + hidden: schema.maybe(schema.boolean()), icon: stringOptionalNullable, id: stringOptionalNullable, ignore_global_filters: numberIntegerOptional, ignore_panel_filters: numberIntegerOptional, index_pattern: stringOptionalNullable, - query_string: queryObject.optional(), + query_string: schema.maybe(queryObject), template: stringOptionalNullable, time_field: stringOptionalNullable, }); -const backgroundColorRulesItems = Joi.object({ - value: Joi.number() - .allow(null) - .optional(), +const backgroundColorRulesItems = schema.object({ + value: schema.maybe(schema.nullable(schema.number())), id: stringOptionalNullable, background_color: stringOptionalNullable, color: stringOptionalNullable, }); -const gaugeColorRulesItems = Joi.object({ +const gaugeColorRulesItems = schema.object({ gauge: stringOptionalNullable, text: stringOptionalNullable, id: stringOptionalNullable, operator: stringOptionalNullable, - value: Joi.number(), + value: schema.number(), }); -const metricsItems = Joi.object({ +const metricsItems = schema.object({ field: stringOptionalNullable, id: stringRequired, metric_agg: stringOptionalNullable, @@ -84,50 +90,49 @@ const metricsItems = Joi.object({ beta: numberOptional, gamma: numberOptional, period: numberOptional, - multiplicative: Joi.boolean(), + multiplicative: schema.maybe(schema.boolean()), window: numberOptional, function: stringOptionalNullable, script: stringOptionalNullable, - variables: Joi.array() - .items( - Joi.object({ + variables: schema.maybe( + schema.arrayOf( + schema.object({ field: stringOptionalNullable, id: stringRequired, name: stringOptionalNullable, }) ) - .optional(), - percentiles: Joi.array() - .items( - Joi.object({ + ), + percentiles: schema.maybe( + schema.arrayOf( + schema.object({ id: stringRequired, field: stringOptionalNullable, - mode: Joi.string().allow('line', 'band'), - shade: Joi.alternatives(numberOptional, stringOptionalNullable), - value: Joi.alternatives(numberOptional, stringOptionalNullable), + mode: schema.oneOf([schema.literal('line'), schema.literal('band')]), + shade: schema.oneOf([numberOptional, stringOptionalNullable]), + value: schema.oneOf([numberOptional, stringOptionalNullable]), percentile: stringOptionalNullable, }) ) - .optional(), + ), type: stringRequired, value: stringOptionalNullable, - values: Joi.array() - .items(Joi.string().allow('', null)) - .allow(null) - .optional(), + values: schema.maybe(schema.nullable(schema.arrayOf(schema.nullable(schema.string())))), }); -const splitFiltersItems = Joi.object({ +const splitFiltersItems = schema.object({ id: stringOptionalNullable, color: stringOptionalNullable, - filter: Joi.object({ - language: Joi.string().allow(''), - query: Joi.string().allow(''), - }).optional(), + filter: schema.maybe( + schema.object({ + language: schema.string(), + query: schema.string(), + }) + ), label: stringOptionalNullable, }); -const seriesItems = Joi.object({ +const seriesItems = schema.object({ aggregate_by: stringOptionalNullable, aggregate_function: stringOptionalNullable, axis_position: stringRequired, @@ -135,31 +140,33 @@ const seriesItems = Joi.object({ axis_min: stringOrNumberOptionalNullable, chart_type: stringRequired, color: stringRequired, - color_rules: Joi.array() - .items( - Joi.object({ + color_rules: schema.maybe( + schema.arrayOf( + schema.object({ value: numberOptional, id: stringRequired, text: stringOptionalNullable, operator: stringOptionalNullable, }) ) - .optional(), + ), fill: numberOptionalOrEmptyString, - filter: Joi.alternatives( - Joi.object({ - query: stringRequired, - language: stringOptionalNullable, - }).optional(), - Joi.string().valid('') + filter: schema.maybe( + schema.oneOf([ + schema.object({ + query: stringRequired, + language: stringOptionalNullable, + }), + schema.literal(''), + ]) ), formatter: stringRequired, hide_in_legend: numberIntegerOptional, - hidden: Joi.boolean().optional(), + hidden: schema.maybe(schema.boolean()), id: stringRequired, label: stringOptionalNullable, line_width: numberOptionalOrEmptyString, - metrics: Joi.array().items(metricsItems), + metrics: schema.arrayOf(metricsItems), offset_time: stringOptionalNullable, override_index_pattern: numberOptional, point_size: numberOptionalOrEmptyString, @@ -170,9 +177,7 @@ const seriesItems = Joi.object({ series_interval: stringOptionalNullable, series_drop_last_bucket: numberIntegerOptional, split_color_mode: stringOptionalNullable, - split_filters: Joi.array() - .items(splitFiltersItems) - .optional(), + split_filters: schema.maybe(schema.arrayOf(splitFiltersItems)), split_mode: stringRequired, stacked: stringRequired, steps: numberIntegerOptional, @@ -189,38 +194,34 @@ const seriesItems = Joi.object({ var_name: stringOptionalNullable, }); -export const visPayloadSchema = Joi.object({ +export const visPayloadSchema = schema.object({ filters: arrayNullable, - panels: Joi.array().items( - Joi.object({ - annotations: Joi.array() - .items(annotationsItems) - .optional(), + panels: schema.arrayOf( + schema.object({ + annotations: schema.maybe(schema.arrayOf(annotationsItems)), axis_formatter: stringRequired, axis_position: stringRequired, axis_scale: stringRequired, axis_min: stringOrNumberOptionalNullable, axis_max: stringOrNumberOptionalNullable, - bar_color_rules: arrayNullable.optional(), + bar_color_rules: schema.maybe(arrayNullable), background_color: stringOptionalNullable, - background_color_rules: Joi.array() - .items(backgroundColorRulesItems) - .optional(), + background_color_rules: schema.maybe(schema.arrayOf(backgroundColorRulesItems)), default_index_pattern: stringOptionalNullable, default_timefield: stringOptionalNullable, drilldown_url: stringOptionalNullable, drop_last_bucket: numberIntegerOptional, - filter: Joi.alternatives( - stringOptionalNullable, - Joi.object({ - language: stringOptionalNullable, - query: stringOptionalNullable, - }) + filter: schema.nullable( + schema.oneOf([ + stringOptionalNullable, + schema.object({ + language: stringOptionalNullable, + query: stringOptionalNullable, + }), + ]) ), - gauge_color_rules: Joi.array() - .items(gaugeColorRulesItems) - .optional(), - gauge_width: [stringOptionalNullable, numberOptional], + gauge_color_rules: schema.maybe(schema.arrayOf(gaugeColorRulesItems)), + gauge_width: schema.nullable(schema.oneOf([stringOptionalNullable, numberOptional])), gauge_inner_color: stringOptionalNullable, gauge_inner_width: stringOrNumberOptionalNullable, gauge_style: stringOptionalNullable, @@ -230,7 +231,7 @@ export const visPayloadSchema = Joi.object({ ignore_global_filter: numberOptional, index_pattern: stringRequired, interval: stringRequired, - isModelInvalid: Joi.boolean().optional(), + isModelInvalid: schema.maybe(schema.boolean()), legend_position: stringOptionalNullable, markdown: stringOptionalNullable, markdown_scrollbars: numberIntegerOptional, @@ -242,9 +243,7 @@ export const visPayloadSchema = Joi.object({ pivot_label: stringOptionalNullable, pivot_type: stringOptionalNullable, pivot_rows: stringOptionalNullable, - series: Joi.array() - .items(seriesItems) - .required(), + series: schema.arrayOf(seriesItems), show_grid: numberIntegerRequired, show_legend: numberIntegerRequired, time_field: stringOptionalNullable, @@ -253,22 +252,19 @@ export const visPayloadSchema = Joi.object({ }) ), // general - query: Joi.array() - .items(queryObject) - .allow(null) - .required(), - state: Joi.object({ - sort: Joi.object({ - column: stringRequired, - order: Joi.string() - .valid(['asc', 'desc']) - .required(), - }).optional(), - }).required(), - savedObjectId: Joi.string().optional(), - timerange: Joi.object({ + query: schema.nullable(schema.arrayOf(queryObject)), + state: schema.object({ + sort: schema.maybe( + schema.object({ + column: stringRequired, + order: schema.oneOf([schema.literal('asc'), schema.literal('desc')]), + }) + ), + }), + savedObjectId: schema.maybe(schema.string()), + timerange: schema.object({ timezone: stringRequired, min: stringRequired, max: stringRequired, - }).required(), + }), }); diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 9abbc4ad617d..744020b58388 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -38,16 +38,18 @@ export const visDataRoutes = ( }, }, async (requestContext, request, response) => { - const { error: validationError } = visPayloadSchema.validate(request.body); - if (validationError) { + try { + visPayloadSchema.validate(request.body); + } catch (error) { logFailedValidation(); const savedObjectId = (typeof request.body === 'object' && (request.body as any).savedObjectId) || 'unavailable'; framework.logger.warn( - `Request validation error: ${validationError.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` + `Request validation error: ${error.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` ); } + try { const results = await getVisData( requestContext, diff --git a/src/test_utils/public/stub_index_pattern.js b/src/test_utils/public/stub_index_pattern.js index 98ada2471e1e..29fb4c20f692 100644 --- a/src/test_utils/public/stub_index_pattern.js +++ b/src/test_utils/public/stub_index_pattern.js @@ -65,7 +65,6 @@ export default function StubIndexPattern(pattern, getConfig, timeField, fields, this.getSourceFiltering = sinon.stub(); this.metaFields = ['_id', '_type', '_source']; this.fieldFormatMap = {}; - this.routes = indexPatterns.getRoutes(); this.getComputedFields = IndexPattern.prototype.getComputedFields.bind(this); this.flattenHit = indexPatterns.flattenHitWrapper(this, this.metaFields); diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index 2d77fdf26679..a0d271755515 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -72,11 +72,26 @@ export default function({ getService }) { attributes: { title: 'A great new dashboard', }, + migrationVersion: { + dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, + }, references: [], }, ], }); })); + + it('should not return raw id when object id is unspecified', async () => + await supertest + .post(`/api/saved_objects/_bulk_create`) + // eslint-disable-next-line no-unused-vars + .send(BULK_REQUESTS.map(({ id, ...rest }) => rest)) + .expect(200) + .then(resp => { + resp.body.saved_objects.map(({ id }) => + expect(id).not.match(/visualization|dashboard/) + ); + })); }); describe('without kibana index', () => { @@ -106,6 +121,9 @@ export default function({ getService }) { title: 'An existing visualization', }, references: [], + migrationVersion: { + visualization: resp.body.saved_objects[0].migrationVersion.visualization, + }, }, { type: 'dashboard', @@ -116,6 +134,9 @@ export default function({ getService }) { title: 'A great new dashboard', }, references: [], + migrationVersion: { + dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, + }, }, ], }); diff --git a/test/common/services/elasticsearch.ts b/test/common/services/elasticsearch.ts index 63c4bfeeb4ce..0436dc901292 100644 --- a/test/common/services/elasticsearch.ts +++ b/test/common/services/elasticsearch.ts @@ -18,8 +18,9 @@ */ import { format as formatUrl } from 'url'; - +import fs from 'fs'; import { Client } from '@elastic/elasticsearch'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../ftr_provider_context'; @@ -27,6 +28,9 @@ export function ElasticsearchProvider({ getService }: FtrProviderContext) { const config = getService('config'); return new Client({ + ssl: { + ca: fs.readFileSync(CA_CERT_PATH, 'utf-8'), + }, nodes: [formatUrl(config.get('servers.elasticsearch'))], requestTimeout: config.get('timeouts.esRequestTimeout'), }); diff --git a/test/functional/apps/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index 20e69ef8345c..0f63510dce43 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -35,7 +35,7 @@ export default function({ getService, getPageObjects }) { describe('discover histogram', function describeIndexTests() { before(async function() { log.debug('load kibana index with default index pattern'); - await PageObjects.common.navigateToApp('home'); + await PageObjects.common.navigateToApp('settings'); await security.testUser.setRoles([ 'kibana_admin', 'test_logstash_reader', diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index 3a3d6b93e166..b0a572d9a54f 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -58,6 +58,7 @@ export default function({ getService, getPageObjects }) { }); it('should create shakespeare index pattern', async function() { + await PageObjects.common.navigateToApp('settings'); log.debug('Create shakespeare index pattern'); await PageObjects.settings.createIndexPattern('shakespeare', null); const patternName = await PageObjects.settings.getIndexPageHeading(); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 93debdcc37f0..4a7570049ded 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -111,7 +111,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo await browser.get(appUrl); } else { log.debug(`navigateToUrl ${appUrl}`); - await browser.get(appUrl); + await browser.get(appUrl, insertTimestamp); // accept alert if it pops up const alert = await browser.getAlert(); await alert?.accept(); @@ -242,7 +242,7 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo let lastUrl = await retry.try(async () => { // since we're using hash URLs, always reload first to force re-render log.debug('navigate to: ' + appUrl); - await browser.get(appUrl); + await browser.get(appUrl, insertTimestamp); // accept alert if it pops up const alert = await browser.getAlert(); await alert?.accept(); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index b7a6e10efd7d..b8069b31257d 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -206,17 +206,15 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async getFieldsTabCount() { return retry.try(async () => { - const indexedFieldsTab = await find.byCssSelector('#indexedFields .euiTab__content'); - const text = await indexedFieldsTab.getVisibleText(); - return text.split(/[()]/)[1]; + const text = await testSubjects.getVisibleText('tab-indexedFields'); + return text.split(' ')[1].replace(/\((.*)\)/, '$1'); }); } async getScriptedFieldsTabCount() { return await retry.try(async () => { - const scriptedFieldsTab = await find.byCssSelector('#scriptedFields .euiTab__content'); - const text = await scriptedFieldsTab.getVisibleText(); - return text.split(/[()]/)[1]; + const text = await testSubjects.getVisibleText('tab-scriptedFields'); + return text.split(' ')[2].replace(/\((.*)\)/, '$1'); }); } @@ -324,7 +322,6 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider isStandardIndexPattern = true ) { await retry.try(async () => { - await this.navigateTo(); await PageObjects.header.waitUntilLoadingHasFinished(); await this.clickKibanaIndexPatterns(); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -432,17 +429,17 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickFieldsTab() { log.debug('click Fields tab'); - await find.clickByCssSelector('#indexedFields'); + await testSubjects.click('tab-indexedFields'); } async clickScriptedFieldsTab() { log.debug('click Scripted Fields tab'); - await find.clickByCssSelector('#scriptedFields'); + await testSubjects.click('tab-scriptedFields'); } async clickSourceFiltersTab() { log.debug('click Source Filters tab'); - await find.clickByCssSelector('#sourceFilters'); + await testSubjects.click('tab-sourceFilters'); } async editScriptedField(name: string) { diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 92f0d090ff5e..4606d93ac27f 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -65,7 +65,7 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo * Sets commonly used time * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... */ - async setCommonlyUsedTime(option: CommonlyUsed) { + async setCommonlyUsedTime(option: CommonlyUsed | string) { await testSubjects.click('superDatePickerToggleQuickMenuButton'); await testSubjects.click(`superDatePickerCommonlyUsed_${option}`); } diff --git a/test/interpreter_functional/test_suites/run_pipeline/basic.ts b/test/interpreter_functional/test_suites/run_pipeline/basic.ts index 51ad789143c5..a2172dd2da1b 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/basic.ts +++ b/test/interpreter_functional/test_suites/run_pipeline/basic.ts @@ -113,11 +113,10 @@ export default function({ await expectExpression('partial_test_2', metricExpr, context).toMatchSnapshot() ).toMatchScreenshot(); - // TODO: should be uncommented when the region map is migrated to the new platform - // const regionMapExpr = `regionmap visConfig='{"metric":{"accessor":1,"format":{"id":"number"}},"bucket":{"accessor":0}}'`; - // await ( - // await expectExpression('partial_test_3', regionMapExpr, context).toMatchSnapshot() - // ).toMatchScreenshot(); + const regionMapExpr = `regionmap visConfig='{"metric":{"accessor":1,"format":{"id":"number"}},"bucket":{"accessor":0}}'`; + await ( + await expectExpression('partial_test_3', regionMapExpr, context).toMatchSnapshot() + ).toMatchScreenshot(); }); }); }); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json new file mode 100644 index 000000000000..6c8d51ccb865 --- /dev/null +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "kbn_tp_embeddable_explorer", + "version": "0.0.1", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "visTypeMarkdown", + "visTypeVislib", + "data", + "embeddable", + "uiActions", + "inspector", + "discover" + ], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/app.tsx similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/app.tsx rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/app.tsx diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx similarity index 98% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx index 16c2840d6a32..e56b82378ddf 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_container_example.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_container_example.tsx @@ -24,7 +24,7 @@ import { DASHBOARD_CONTAINER_TYPE, DashboardContainer, DashboardContainerInput, -} from '../../../../../../../../src/plugins/dashboard/public'; +} from '../../../../../../src/plugins/dashboard/public'; import { dashboardInput } from './dashboard_input'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts similarity index 96% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts index 37ef8cad948c..6f4e1f052f5e 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/dashboard_input.ts +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/dashboard_input.ts @@ -18,7 +18,7 @@ */ import { ViewMode, CONTACT_CARD_EMBEDDABLE, HELLO_WORLD_EMBEDDABLE } from '../embeddable_api'; -import { DashboardContainerInput } from '../../../../../../../../src/plugins/dashboard/public'; +import { DashboardContainerInput } from '../../../../../../src/plugins/dashboard/public'; export const dashboardInput: DashboardContainerInput = { panels: { diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/index.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/index.ts similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/app/index.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/app/index.ts diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts new file mode 100644 index 000000000000..9f6597fefa1e --- /dev/null +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/embeddable_api.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from '../../../../../src/plugins/embeddable/public'; +export * from '../../../../../src/plugins/embeddable/public/lib/test_samples'; +export { + HELLO_WORLD_EMBEDDABLE, + HelloWorldEmbeddableFactory, +} from '../../../../../examples/embeddable_examples/public'; diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/index.ts similarity index 100% rename from test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.ts rename to test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/index.ts diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json deleted file mode 100644 index d0d0784eae8d..000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/kibana.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "kbn_tp_embeddable_explorer", - "version": "kibana", - "requiredPlugins": [ - "embeddable", - "inspector" - ], - "server": false, - "ui": true -} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html deleted file mode 100644 index a242631e1638..000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
ANGULAR STUFF!
- diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts deleted file mode 100644 index 6d125bc3002e..000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/legacy.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ -import 'ui/autoload/all'; - -import 'uiExports/interpreter'; -import 'uiExports/embeddableFactories'; -import 'uiExports/embeddableActions'; -import 'uiExports/contextMenuActions'; -import 'uiExports/devTools'; -import 'uiExports/docViews'; -import 'uiExports/embeddableActions'; -import 'uiExports/fieldFormatEditors'; -import 'uiExports/fieldFormats'; -import 'uiExports/home'; -import 'uiExports/indexManagement'; -import 'uiExports/inspectorViews'; -import 'uiExports/savedObjectTypes'; -import 'uiExports/search'; -import 'uiExports/shareContextMenuExtensions'; -import 'uiExports/visTypes'; -import 'uiExports/visualize'; - -import { npSetup, npStart } from 'ui/new_platform'; -import { ExitFullScreenButton } from 'ui/exit_full_screen'; -import uiRoutes from 'ui/routes'; -// @ts-ignore -import { uiModules } from 'ui/modules'; -/* eslint-enable @kbn/eslint/no-restricted-paths */ - -import template from './index.html'; - -import { plugin } from '.'; - -const pluginInstance = plugin({} as any); - -export const setup = pluginInstance.setup(npSetup.core, { - embeddable: npSetup.plugins.embeddable, - inspector: npSetup.plugins.inspector, - __LEGACY: { - ExitFullScreenButton, - }, -}); - -let rendered = false; -const onRenderCompleteListeners: Array<() => void> = []; - -uiRoutes.enable(); -uiRoutes.defaults(/\embeddable_explorer/, {}); -uiRoutes.when('/', { - template, - controller($scope) { - $scope.$$postDigest(() => { - rendered = true; - onRenderCompleteListeners.forEach(listener => listener()); - }); - }, -}); - -export const start = pluginInstance.start(npStart.core, { - embeddable: npStart.plugins.embeddable, - inspector: npStart.plugins.inspector, - uiActions: npStart.plugins.uiActions, - __LEGACY: { - ExitFullScreenButton, - onRenderComplete: (renderCompleteListener: () => void) => { - if (rendered) { - renderCompleteListener(); - } else { - onRenderCompleteListeners.push(renderCompleteListener); - } - }, - }, -}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx deleted file mode 100644 index b47e84216dd1..000000000000 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; -import { createHelloWorldAction } from '../../../../../../../src/plugins/ui_actions/public/tests/test_samples'; - -import { - Start as InspectorStartContract, - Setup as InspectorSetupContract, -} from '../../../../../../../src/plugins/inspector/public'; - -import { CONTEXT_MENU_TRIGGER } from './embeddable_api'; - -const REACT_ROOT_ID = 'embeddableExplorerRoot'; - -import { SayHelloAction, createSendMessageAction } from './embeddable_api'; -import { App } from './app'; -import { - EmbeddableStart, - EmbeddableSetup, -} from '.../../../../../../../src/plugins/embeddable/public'; - -export interface SetupDependencies { - embeddable: EmbeddableSetup; - inspector: InspectorSetupContract; - __LEGACY: { - ExitFullScreenButton: React.ComponentType; - }; -} - -interface StartDependencies { - embeddable: EmbeddableStart; - uiActions: UiActionsStart; - inspector: InspectorStartContract; - __LEGACY: { - ExitFullScreenButton: React.ComponentType; - onRenderComplete: (onRenderComplete: () => void) => void; - }; -} - -export type EmbeddableExplorerSetup = void; -export type EmbeddableExplorerStart = void; - -export class EmbeddableExplorerPublicPlugin - implements - Plugin { - public setup(core: CoreSetup, setupDeps: SetupDependencies): EmbeddableExplorerSetup {} - - public start(core: CoreStart, plugins: StartDependencies): EmbeddableExplorerStart { - const helloWorldAction = createHelloWorldAction(core.overlays); - const sayHelloAction = new SayHelloAction(alert); - const sendMessageAction = createSendMessageAction(core.overlays); - - plugins.uiActions.registerAction(sayHelloAction); - plugins.uiActions.registerAction(sendMessageAction); - - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); - - plugins.__LEGACY.onRenderComplete(() => { - const root = document.getElementById(REACT_ROOT_ID); - ReactDOM.render(, root); - }); - } - - public stop() {} -} diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx new file mode 100644 index 000000000000..f99d89ca630b --- /dev/null +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/plugin.tsx @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, Plugin, AppMountParameters } from 'kibana/public'; +import { UiActionsStart, UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; +import { createHelloWorldAction } from '../../../../../src/plugins/ui_actions/public/tests/test_samples'; + +import { + Start as InspectorStartContract, + Setup as InspectorSetupContract, +} from '../../../../../src/plugins/inspector/public'; + +import { App } from './app'; +import { + CONTEXT_MENU_TRIGGER, + CONTACT_CARD_EMBEDDABLE, + HELLO_WORLD_EMBEDDABLE, + HelloWorldEmbeddableFactory, + ContactCardEmbeddableFactory, + SayHelloAction, + createSendMessageAction, +} from './embeddable_api'; +import { + EmbeddableStart, + EmbeddableSetup, +} from '.../../../../../../../src/plugins/embeddable/public'; + +export interface SetupDependencies { + embeddable: EmbeddableSetup; + inspector: InspectorSetupContract; + uiActions: UiActionsSetup; +} + +interface StartDependencies { + embeddable: EmbeddableStart; + uiActions: UiActionsStart; + inspector: InspectorStartContract; +} + +export type EmbeddableExplorerSetup = void; +export type EmbeddableExplorerStart = void; + +export class EmbeddableExplorerPublicPlugin + implements + Plugin { + public setup(core: CoreSetup, setupDeps: SetupDependencies): EmbeddableExplorerSetup { + const helloWorldAction = createHelloWorldAction({} as any); + const sayHelloAction = new SayHelloAction(alert); + const sendMessageAction = createSendMessageAction({} as any); + + setupDeps.uiActions.registerAction(helloWorldAction); + setupDeps.uiActions.registerAction(sayHelloAction); + setupDeps.uiActions.registerAction(sendMessageAction); + + setupDeps.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction.id); + + setupDeps.embeddable.registerEmbeddableFactory( + HELLO_WORLD_EMBEDDABLE, + new HelloWorldEmbeddableFactory() + ); + + setupDeps.embeddable.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) + ); + + core.application.register({ + id: 'EmbeddableExplorer', + title: 'Embeddable Explorer', + async mount(params: AppMountParameters) { + const startPlugins = (await core.getStartServices())[1] as StartDependencies; + render(, params.element); + + return () => unmountComponentAtNode(params.element); + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a033515fef8b..7c464d44d576 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -14,6 +14,7 @@ "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", + "xpack.embeddableEnhanced": "plugins/embeddable_enhanced", "xpack.endpoint": "plugins/endpoint", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts index 2f93765165e5..3999393600e4 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_element_position_data.ts @@ -6,16 +6,17 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger as Logger, startTrace } from '../../../../server/lib'; import { LayoutInstance } from '../../layouts/layout'; -import { AttributesMap, ElementsPositionAndAttribute } from './types'; -import { Logger } from '../../../../types'; import { CONTEXT_ELEMENTATTRIBUTES } from './constants'; +import { AttributesMap, ElementsPositionAndAttribute } from './types'; export const getElementPositionAndAttributes = async ( browser: HeadlessBrowser, layout: LayoutInstance, logger: Logger ): Promise => { + const endTrace = startTrace('get_element_position_data', 'read'); const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; try { @@ -69,5 +70,7 @@ export const getElementPositionAndAttributes = async ( elementsPositionAndAttributes = null; } + endTrace(); + return elementsPositionAndAttributes; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 57d025890d3e..d0c1a2a3ce67 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; @@ -17,6 +17,7 @@ export const getNumberOfItems = async ( layout: LayoutInstance, logger: LevelLogger ): Promise => { + const endTrace = startTrace('get_number_of_items', 'read'); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -70,5 +71,7 @@ export const getNumberOfItems = async ( itemsCount = 1; } + endTrace(); + return itemsCount; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts index d50ac64743f0..bc9e17854b27 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_screenshots.ts @@ -6,26 +6,9 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { Screenshot, ElementsPositionAndAttribute } from './types'; -const getAsyncDurationLogger = (logger: LevelLogger) => { - return async (description: string, promise: Promise) => { - const start = Date.now(); - const result = await promise; - logger.debug( - i18n.translate('xpack.reporting.screencapture.asyncTook', { - defaultMessage: '{description} took {took}ms', - values: { - description, - took: Date.now() - start, - }, - }) - ); - return result; - }; -}; - export const getScreenshots = async ( browser: HeadlessBrowser, elementsPositionAndAttributes: ElementsPositionAndAttribute[], @@ -37,21 +20,20 @@ export const getScreenshots = async ( }) ); - const asyncDurationLogger = getAsyncDurationLogger(logger); const screenshots: Screenshot[] = []; for (let i = 0; i < elementsPositionAndAttributes.length; i++) { + const endTrace = startTrace('get_screenshots', 'read'); const item = elementsPositionAndAttributes[i]; - const base64EncodedData = await asyncDurationLogger( - `screenshot #${i + 1}`, - browser.screenshot(item.position) - ); + const base64EncodedData = await browser.screenshot(item.position); screenshots.push({ base64EncodedData, title: item.attributes.title, description: item.attributes.description, }); + + endTrace(); } logger.info( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts index c1c43ed45259..bcd4cf9000df 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_time_range.ts @@ -5,7 +5,7 @@ */ import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETTIMERANGE } from './constants'; import { TimeRange } from './types'; @@ -15,6 +15,7 @@ export const getTimeRange = async ( layout: LayoutInstance, logger: LevelLogger ): Promise => { + const endTrace = startTrace('get_time_range', 'read'); logger.debug('getting timeRange'); const timeRange: TimeRange | null = await browser.evaluate( @@ -45,5 +46,7 @@ export const getTimeRange = async ( logger.debug('no timeRange'); } + endTrace(); + return timeRange; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts index cb2673e85186..40bb84870b16 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/inject_css.ts @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; import fs from 'fs'; import { promisify } from 'util'; -import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { Layout } from '../../layouts/layout'; import { CONTEXT_INJECTCSS } from './constants'; @@ -19,6 +19,7 @@ export const injectCustomCss = async ( layout: Layout, logger: LevelLogger ): Promise => { + const endTrace = startTrace('inject_css', 'correction'); logger.debug( i18n.translate('xpack.reporting.screencapture.injectingCss', { defaultMessage: 'injecting custom css', @@ -49,4 +50,6 @@ export const injectCustomCss = async ( }) ); } + + endTrace(); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index eb96753f0ce1..282490a28d59 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -4,8 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; -import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; +import { + catchError, + concatMap, + first, + mergeMap, + take, + takeUntil, + tap, + toArray, +} from 'rxjs/operators'; import { CaptureConfig } from '../../../../server/types'; import { DEFAULT_PAGELOAD_SELECTOR } from '../../constants'; import { HeadlessChromiumDriverFactory } from '../../../../types'; @@ -41,6 +51,9 @@ export function screenshotsObservableFactory( layout, browserTimezone, }: ScreenshotObservableOpts): Rx.Observable { + const apmTrans = apm.startTransaction(`reporting screenshot pipeline`, 'reporting'); + + const apmCreatePage = apmTrans?.startSpan('create_page', 'wait'); const create$ = browserDriverFactory.createPage( { viewport: layout.getBrowserViewport(), browserTimezone }, logger @@ -48,6 +61,7 @@ export function screenshotsObservableFactory( return create$.pipe( mergeMap(({ driver, exit$ }) => { + if (apmCreatePage) apmCreatePage.end(); return Rx.from(urls).pipe( concatMap((url, index) => { const setup$: Rx.Observable = Rx.of(1).pipe( @@ -81,10 +95,12 @@ export function screenshotsObservableFactory( // allows for them to be displayed properly in many cases await injectCustomCss(driver, layout, logger); + const apmPositionElements = apmTrans?.startSpan('position_elements', 'correction'); if (layout.positionElements) { // position panel elements for print layout await layout.positionElements(driver, logger); } + if (apmPositionElements) apmPositionElements.end(); await waitForRenderComplete(captureConfig, driver, layout, logger); }), @@ -125,7 +141,10 @@ export function screenshotsObservableFactory( toArray() ); }), - first() + first(), + tap(() => { + if (apmTrans) apmTrans.end(); + }) ); }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index 92a58aded5f6..a0708b7dba36 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { ConditionalHeaders } from '../../../../types'; @@ -18,6 +18,7 @@ export const openUrl = async ( conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { + const endTrace = startTrace('open_url', 'wait'); try { await browser.open( url, @@ -32,11 +33,10 @@ export const openUrl = async ( throw new Error( i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, - values: { - configKey: 'xpack.reporting.capture.timeouts.openUrl', - error: err, - }, + values: { configKey: 'xpack.reporting.capture.timeouts.openUrl', error: err }, }) ); } + + endTrace(); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index e113a5d228cd..13ddf5eb74fc 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -30,7 +30,7 @@ export interface ElementsPositionAndAttribute { } export interface Screenshot { - base64EncodedData: Buffer; + base64EncodedData: string; title: string; description: string; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 069896c8d9e9..fe92fbc9077e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; @@ -17,6 +17,8 @@ export const waitForRenderComplete = async ( layout: LayoutInstance, logger: LevelLogger ) => { + const endTrace = startTrace('wait_for_render', 'wait'); + logger.debug( i18n.translate('xpack.reporting.screencapture.waitingForRenderComplete', { defaultMessage: 'waiting for rendering to complete', @@ -76,5 +78,7 @@ export const waitForRenderComplete = async ( defaultMessage: 'rendering is complete', }) ); + + endTrace(); }); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 7960e1552e55..d456c4089ece 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; -import { LevelLogger } from '../../../../server/lib'; +import { LevelLogger, startTrace } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -29,6 +29,7 @@ export const waitForVisualizations = async ( layout: LayoutInstance, logger: LevelLogger ): Promise => { + const endTrace = startTrace('wait_for_visualizations', 'wait'); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -63,4 +64,6 @@ export const waitForVisualizations = async ( }) ); } + + endTrace(); }; diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts index 9d3deda5d98b..fd879f098723 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.ts @@ -126,7 +126,7 @@ test(`returns content_type of application/png`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + generatePngObservable.mockReturnValue(Rx.of('foo')); const { content_type: contentType } = await executeJob( 'pngJobId', @@ -137,10 +137,10 @@ test(`returns content_type of application/png`, async () => { }); test(`returns content of generatePng getBuffer base64 encoded`, async () => { - const testContent = 'test content'; + const testContent = 'raw string from get_screenhots'; const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); + generatePngObservable.mockReturnValue(Rx.of({ base64: testContent })); const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); @@ -150,5 +150,5 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { cancellationToken ); - expect(content).toEqual(Buffer.from(testContent).toString('base64')); + expect(content).toEqual(testContent); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 0ffd42d0b52f..88c2d8a9fe4b 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; @@ -29,6 +30,10 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut const logger = parentLogger.clone([PNG_JOB_TYPE, 'execute']); return async function executeJob(jobId: string, job: JobDocPayloadPNG, cancellationToken: any) { + const apmTrans = apm.startTransaction('reporting execute_job png', 'reporting'); + const apmGetAssets = apmTrans?.startSpan('get_assets', 'setup'); + let apmGeneratePng: { end: () => void } | null | undefined; + const generatePngObservable = await generatePngObservableFactory(reporting); const jobLogger = logger.clone([jobId]); const process$: Rx.Observable = Rx.of(1).pipe( @@ -38,6 +43,9 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut mergeMap(conditionalHeaders => { const urls = getFullUrls({ config, job }); const hashUrl = urls[0]; + if (apmGetAssets) apmGetAssets.end(); + + apmGeneratePng = apmTrans?.startSpan('generate_png_pipeline', 'execute'); return generatePngObservable( jobLogger, hashUrl, @@ -46,11 +54,14 @@ export const executeJobFactory: QueuedPngExecutorFactory = async function execut job.layout ); }), - map(({ buffer, warnings }) => { + map(({ base64, warnings }) => { + if (apmGeneratePng) apmGeneratePng.end(); + return { content_type: 'image/png', - content: buffer.toString('base64'), - size: buffer.byteLength, + content: base64, + size: (base64 && base64.length) || 0, + warnings, }; }), diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index c03ea170f76e..c79aa2818705 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { ReportingCore } from '../../../../server'; @@ -22,12 +23,16 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { browserTimezone: string, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams - ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { + ): Rx.Observable<{ base64: string | null; warnings: string[] }> { + const apmTrans = apm.startTransaction('reporting generate_png', 'reporting'); + const apmLayout = apmTrans?.startSpan('create_layout', 'setup'); if (!layoutParams || !layoutParams.dimensions) { throw new Error(`LayoutParams.Dimensions is undefined.`); } - const layout = new PreserveLayout(layoutParams.dimensions); + if (apmLayout) apmLayout.end(); + + const apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', 'setup'); const screenshots$ = getScreenshots({ logger, urls: [url], @@ -36,8 +41,11 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { browserTimezone, }).pipe( map((results: ScreenshotResults[]) => { + if (apmScreenshots) apmScreenshots.end(); + if (apmTrans) apmTrans.end(); + return { - buffer: results[0].screenshots[0].base64EncodedData, + base64: results[0].screenshots[0].base64EncodedData, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index 3d69042b6c7a..5aad66c53a99 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; @@ -31,6 +32,10 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut const logger = parentLogger.clone([PDF_JOB_TYPE, 'execute']); return async function executeJob(jobId: string, job: JobDocPayloadPDF, cancellationToken: any) { + const apmTrans = apm.startTransaction('reporting execute_job pdf', 'reporting'); + const apmGetAssets = apmTrans?.startSpan('get_assets', 'setup'); + let apmGeneratePdf: { end: () => void } | null | undefined; + const generatePdfObservable = await generatePdfObservableFactory(reporting); const jobLogger = logger.clone([jobId]); @@ -43,6 +48,9 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut const urls = getFullUrls({ config, job }); const { browserTimezone, layout, title } = job; + if (apmGetAssets) apmGetAssets.end(); + + apmGeneratePdf = apmTrans?.startSpan('generate_pdf_pipeline', 'execute'); return generatePdfObservable( jobLogger, title, @@ -53,12 +61,20 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut logo ); }), - map(({ buffer, warnings }) => ({ - content_type: 'application/pdf', - content: buffer.toString('base64'), - size: buffer.byteLength, - warnings, - })), + map(({ buffer, warnings }) => { + if (apmGeneratePdf) apmGeneratePdf.end(); + + const apmEncode = apmTrans?.startSpan('encode_pdf', 'output'); + const content = buffer?.toString('base64') || null; + if (apmEncode) apmEncode.end(); + + return { + content_type: 'application/pdf', + content, + size: buffer?.byteLength || 0, + warnings, + }; + }), catchError(err => { jobLogger.error(err); return Rx.throwError(err); @@ -66,6 +82,8 @@ export const executeJobFactory: QueuedPdfExecutorFactory = async function execut ); const stop$ = Rx.fromEventPattern(cancellationToken.on); + + if (apmTrans) apmTrans.end(); return process$.pipe(takeUntil(stop$)).toPromise(); }; }; diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index c882ef682f95..238accba8b1d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -13,6 +13,7 @@ import { ConditionalHeaders } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; +import { getTracker } from './tracker'; // @ts-ignore untyped module import { pdf } from './pdf'; @@ -39,8 +40,14 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams, logo?: string - ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { + ): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { + const tracker = getTracker(); + tracker.startLayout(); + const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; + tracker.endLayout(); + + tracker.startScreenshots(); const screenshots$ = getScreenshots({ logger, urls, @@ -49,16 +56,22 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { browserTimezone, }).pipe( mergeMap(async (results: ScreenshotResults[]) => { - const pdfOutput = pdf.create(layout, logo); + tracker.endScreenshots(); + tracker.startSetup(); + const pdfOutput = pdf.create(layout, logo); if (title) { const timeRange = getTimeRange(results); title += timeRange ? ` - ${timeRange.duration}` : ''; pdfOutput.setTitle(title); } + tracker.endSetup(); results.forEach(r => { r.screenshots.forEach(screenshot => { + logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.base64EncodedData?.length || 0}`); // prettier-ignore + tracker.startAddImage(); + tracker.endAddImage(); pdfOutput.addImage(screenshot.base64EncodedData, { title: screenshot.title, description: screenshot.description, @@ -66,10 +79,26 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { }); }); - pdfOutput.generate(); + let buffer: Buffer | null = null; + try { + tracker.startCompile(); + logger.debug(`Compiling PDF...`); + pdfOutput.generate(); + tracker.endCompile(); + + tracker.startGetBuffer(); + logger.debug(`Generating PDF Buffer...`); + buffer = await pdfOutput.getBuffer(); + logger.debug(`PDF buffer byte length: ${buffer?.byteLength || 0}`); + tracker.endGetBuffer(); + } catch (err) { + logger.error(`Could not generate the PDF buffer! ${err}`); + } + + tracker.end(); return { - buffer: await pdfOutput.getBuffer(), + buffer, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/tracker.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/tracker.ts new file mode 100644 index 000000000000..b6fad243db7b --- /dev/null +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/tracker.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import apm from 'elastic-apm-node'; + +interface PdfTracker { + startLayout: () => void; + endLayout: () => void; + startScreenshots: () => void; + endScreenshots: () => void; + startSetup: () => void; + endSetup: () => void; + startAddImage: () => void; + endAddImage: () => void; + startCompile: () => void; + endCompile: () => void; + startGetBuffer: () => void; + endGetBuffer: () => void; + end: () => void; +} + +const SPANTYPE_SETUP = 'setup'; +const SPANTYPE_OUTPUT = 'output'; + +interface ApmSpan { + end: () => void; +} + +export function getTracker(): PdfTracker { + const apmTrans = apm.startTransaction('reporting generate_pdf', 'reporting'); + + let apmLayout: ApmSpan | null = null; + let apmScreenshots: ApmSpan | null = null; + let apmSetup: ApmSpan | null = null; + let apmAddImage: ApmSpan | null = null; + let apmCompilePdf: ApmSpan | null = null; + let apmGetBuffer: ApmSpan | null = null; + + return { + startLayout() { + apmLayout = apmTrans?.startSpan('create_layout', SPANTYPE_SETUP) || null; + }, + endLayout() { + if (apmLayout) apmLayout.end(); + }, + startScreenshots() { + apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', SPANTYPE_SETUP) || null; + }, + endScreenshots() { + if (apmScreenshots) apmScreenshots.end(); + }, + startSetup() { + apmSetup = apmTrans?.startSpan('setup_pdf', SPANTYPE_SETUP) || null; + }, + endSetup() { + if (apmSetup) apmSetup.end(); + }, + startAddImage() { + apmAddImage = apmTrans?.startSpan('add_pdf_image', SPANTYPE_OUTPUT) || null; + }, + endAddImage() { + if (apmAddImage) apmAddImage.end(); + }, + startCompile() { + apmCompilePdf = apmTrans?.startSpan('compile_pdf', SPANTYPE_OUTPUT) || null; + }, + endCompile() { + if (apmCompilePdf) apmCompilePdf.end(); + }, + startGetBuffer() { + apmGetBuffer = apmTrans?.startSpan('get_buffer', SPANTYPE_OUTPUT) || null; + }, + endGetBuffer() { + if (apmGetBuffer) apmGetBuffer.end(); + }, + end() { + if (apmTrans) apmTrans.end(); + }, + }; +} diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index 16b8fbdb30fd..ad0f05c02a1f 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -11,18 +11,13 @@ import { ESQueueInstance, ESQueueWorkerExecuteFn, ExportTypeDefinition, - ImmediateExecuteFn, - JobDocPayload, JobSource, Logger, - RequestFacade, } from '../../types'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; export function createWorkerFactory(reporting: ReportingCore, logger: Logger) { - type JobDocPayloadType = JobDocPayload; - const config = reporting.getConfig(); const queueConfig = config.get('queue'); const kibanaName = config.kbnConfig.get('server', 'name'); @@ -31,48 +26,36 @@ export function createWorkerFactory(reporting: ReportingCore, log // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { // export type / execute job map - const jobExecutors: Map< - string, - ImmediateExecuteFn | ESQueueWorkerExecuteFn - > = new Map(); + const jobExecutors: Map> = new Map(); for (const exportType of reporting.getExportTypesRegistry().getAll() as Array< - ExportTypeDefinition< - JobParamsType, - unknown, - unknown, - ImmediateExecuteFn | ESQueueWorkerExecuteFn - > + ExportTypeDefinition> >) { const jobExecutor = await exportType.executeJobFactory(reporting, logger); // FIXME: does not "need" to be async jobExecutors.set(exportType.jobType, jobExecutor); } - const workerFn = (jobSource: JobSource, ...workerRestArgs: any[]) => { + const workerFn = ( + jobSource: JobSource, + jobParams: ScheduledTaskParamsType, + cancellationToken: CancellationToken + ) => { const { _id: jobId, _source: { jobtype: jobType }, } = jobSource; + if (!jobId) { + throw new Error(`Claimed job is missing an ID!: ${JSON.stringify(jobSource)}`); + } + const jobTypeExecutor = jobExecutors.get(jobType); - // pass the work to the jobExecutor if (!jobTypeExecutor) { throw new Error(`Unable to find a job executor for the claimed job: [${jobId}]`); } - if (jobId) { - const jobExecutorWorker = jobTypeExecutor as ESQueueWorkerExecuteFn; - return jobExecutorWorker( - jobId, - ...(workerRestArgs as [JobDocPayloadType, CancellationToken]) - ); - } else { - const jobExecutorImmediate = jobExecutors.get(jobType) as ImmediateExecuteFn; - return jobExecutorImmediate( - null, - ...(workerRestArgs as [JobDocPayload, RequestFacade]) - ); - } + // pass the work to the jobExecutor + return jobTypeExecutor(jobId, jobParams, cancellationToken); }; const workerOptions = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index 3e87337dc435..8f33d9b73566 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -9,7 +9,6 @@ import { ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, - ImmediateCreateJobFn, Job, Logger, RequestFacade, @@ -40,7 +39,7 @@ export function enqueueJobFactory(reporting: ReportingCore, parentLogger: Logger headers: ConditionalHeaders['headers'], request: RequestFacade ): Promise { - type CreateJobFn = ESQueueCreateJobFn | ImmediateCreateJobFn; + type CreateJobFn = ESQueueCreateJobFn; const esqueue = await reporting.getEsqueue(); const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index f5ccbe493a91..2a8fa45b6fce 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +export { LevelLogger } from './level_logger'; export { checkLicenseFactory } from './check_license'; export { createQueueFactory } from './create_queue'; export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; export { getExportTypesRegistry } from './export_types_registry'; -export { LevelLogger } from './level_logger'; export { runValidations } from './validate'; +export { startTrace } from './trace'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/trace.ts b/x-pack/legacy/plugins/reporting/server/lib/trace.ts new file mode 100644 index 000000000000..2d79d17715d0 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/trace.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import apm from 'elastic-apm-node'; + +export function startTrace(name: string, category: string) { + const span = apm.startSpan(name, category); + return () => { + if (span) span.end(); + }; +} diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 4c8cc3aa503e..54624b94e0de 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -98,7 +98,7 @@ Built-In-Actions are configured using the _xpack.actions_ namespoace under _kiba | _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | | _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | | _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | -| _xpack.actions._**preconfigured** | A list of preconfigured actions. Default: `[]` | Array | +| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | #### Whitelisting Built-in Action Types diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 161a6c31d4e5..e86f2d783282 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -14,7 +14,7 @@ describe('config validation', () => { "enabledActionTypes": Array [ "*", ], - "preconfigured": Array [], + "preconfigured": Object {}, "whitelistedHosts": Array [ "*", ], @@ -24,16 +24,15 @@ describe('config validation', () => { test('action with preconfigured actions', () => { const config: Record = { - preconfigured: [ - { - id: 'my-slack1', + preconfigured: { + mySlack1: { actionTypeId: '.slack', name: 'Slack #xyz', config: { webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz', }, }, - ], + }, }; expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { @@ -41,21 +40,57 @@ describe('config validation', () => { "enabledActionTypes": Array [ "*", ], - "preconfigured": Array [ - Object { + "preconfigured": Object { + "mySlack1": Object { "actionTypeId": ".slack", "config": Object { "webhookUrl": "https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz", }, - "id": "my-slack1", "name": "Slack #xyz", "secrets": Object {}, }, - ], + }, "whitelistedHosts": Array [ "*", ], } `); }); + + test('validates preconfigured action ids', () => { + expect(() => + configSchema.validate(preConfiguredActionConfig('')) + ).toThrowErrorMatchingInlineSnapshot( + `"[preconfigured]: invalid preconfigured action id \\"\\""` + ); + + expect(() => + configSchema.validate(preConfiguredActionConfig('constructor')) + ).toThrowErrorMatchingInlineSnapshot( + `"[preconfigured]: invalid preconfigured action id \\"constructor\\""` + ); + + expect(() => + configSchema.validate(preConfiguredActionConfig('__proto__')) + ).toThrowErrorMatchingInlineSnapshot( + `"[preconfigured]: invalid preconfigured action id \\"__proto__\\""` + ); + }); }); + +// object creator that ensures we can create a property named __proto__ on an +// object, via JSON.parse() +function preConfiguredActionConfig(id: string) { + return JSON.parse(`{ + "preconfigured": { + ${JSON.stringify(id)}: { + "actionTypeId": ".server-log", + "name": "server log 1" + }, + "serverLog": { + "actionTypeId": ".server-log", + "name": "server log 2" + } + } + }`); +} diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 1f04efd1941b..b2f3fa2680a9 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -7,6 +7,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { WhitelistedHosts, EnabledActionTypes } from './actions_config'; +const preconfiguredActionSchema = schema.object({ + name: schema.string({ minLength: 1 }), + actionTypeId: schema.string({ minLength: 1 }), + config: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), + secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), +}); + export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), whitelistedHosts: schema.arrayOf( @@ -21,18 +28,26 @@ export const configSchema = schema.object({ defaultValue: [WhitelistedHosts.Any], } ), - preconfigured: schema.arrayOf( - schema.object({ - id: schema.string({ minLength: 1 }), - name: schema.string(), - actionTypeId: schema.string({ minLength: 1 }), - config: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), - secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), - }), - { - defaultValue: [], - } - ), + preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, { + defaultValue: {}, + validate: validatePreconfigured, + }), }); export type ActionsConfig = TypeOf; + +const invalidActionIds = new Set(['', '__proto__', 'constructor']); + +function validatePreconfigured(preconfigured: Record): string | undefined { + // check for ids that should not be used + for (const id of Object.keys(preconfigured)) { + if (invalidActionIds.has(id)) { + return `invalid preconfigured action id "${id}"`; + } + } + + // in case __proto__ was used as a preconfigured action id ... + if (Object.getPrototypeOf(preconfigured) !== Object.getPrototypeOf({})) { + return `invalid preconfigured action id "__proto__"`; + } +} diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 2b334953063d..8673d992ada9 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -12,6 +12,7 @@ import { taskManagerMock } from '../../task_manager/server/mocks'; import { eventLogMock } from '../../event_log/server/mocks'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ActionType } from './types'; +import { ActionsConfig } from './config'; import { ActionsPlugin, ActionsPluginsSetup, @@ -31,33 +32,11 @@ describe('Actions Plugin', () => { let pluginsSetup: jest.Mocked; beforeEach(() => { - context = coreMock.createPluginInitializerContext({ - preconfigured: [ - { - id: 'my-slack1', - actionTypeId: '.slack', - name: 'Slack #xyz', - description: 'Send a message to the #xyz channel', - config: { - webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz', - }, - }, - { - id: 'custom-system-abc-connector', - actionTypeId: 'system-abc-action-type', - description: 'Send a notification to system ABC', - name: 'System ABC', - config: { - xyzConfig1: 'value1', - xyzConfig2: 'value2', - listOfThings: ['a', 'b', 'c', 'd'], - }, - secrets: { - xyzSecret1: 'credential1', - xyzSecret2: 'credential2', - }, - }, - ], + context = coreMock.createPluginInitializerContext({ + enabled: true, + enabledActionTypes: ['*'], + whitelistedHosts: ['*'], + preconfigured: {}, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -192,6 +171,7 @@ describe('Actions Plugin', () => { }); }); }); + describe('start()', () => { let plugin: ActionsPlugin; let coreSetup: ReturnType; @@ -200,8 +180,18 @@ describe('Actions Plugin', () => { let pluginsStart: jest.Mocked; beforeEach(() => { - const context = coreMock.createPluginInitializerContext({ - preconfigured: [], + const context = coreMock.createPluginInitializerContext({ + enabled: true, + enabledActionTypes: ['*'], + whitelistedHosts: ['*'], + preconfigured: { + preconfiguredServerLog: { + actionTypeId: '.server-log', + name: 'preconfigured-server-log', + config: {}, + secrets: {}, + }, + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -220,6 +210,15 @@ describe('Actions Plugin', () => { }); describe('getActionsClientWithRequest()', () => { + it('should handle preconfigured actions', async () => { + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await plugin.setup(coreSetup as any, pluginsSetup); + const pluginStart = plugin.start(coreStart, pluginsStart); + + expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true); + }); + it('should not throw error when ESO plugin not using a generated key', async () => { // coreMock.createSetup doesn't support Plugin generics // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index f14df794bbf4..bc7440c8bee4 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -150,12 +150,14 @@ export class ActionsPlugin implements Plugin, Plugi const actionsConfig = (await this.config) as ActionsConfig; const actionsConfigUtils = getActionsConfigurationUtilities(actionsConfig); - this.preconfiguredActions.push( - ...actionsConfig.preconfigured.map( - preconfiguredAction => - ({ ...preconfiguredAction, isPreconfigured: true } as PreConfiguredAction) - ) - ); + for (const preconfiguredId of Object.keys(actionsConfig.preconfigured)) { + this.preconfiguredActions.push({ + ...actionsConfig.preconfigured[preconfiguredId], + id: preconfiguredId, + isPreconfigured: true, + }); + } + const actionTypeRegistry = new ActionTypeRegistry({ taskRunnerFactory, taskManager: plugins.taskManager, diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 867ead688d23..4d14226777a0 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -32,9 +32,9 @@ export interface ActionWizardProps { /** * Action factory selected changed - * null - means user click "change" and removed action factory selection + * empty - means user click "change" and removed action factory selection */ - onActionFactoryChange: (actionFactory: ActionFactory | null) => void; + onActionFactoryChange: (actionFactory?: ActionFactory) => void; /** * current config for currently selected action factory @@ -71,7 +71,7 @@ export const ActionWizard: React.FC = ({ actionFactory={currentActionFactory} showDeselect={actionFactories.length > 1} onDeselect={() => { - onActionFactoryChange(null); + onActionFactoryChange(undefined); }} context={context} config={config} diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index c3e749f163c9..692e86b53f09 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -167,7 +167,7 @@ export function Demo({ actionFactories }: { actionFactories: Array({}); - function changeActionFactory(newActionFactory: ActionFactory | null) { + function changeActionFactory(newActionFactory?: ActionFactory) { if (!newActionFactory) { // removing action factory return setState({}); diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts index f1aef5deff49..262a5ef7d456 100644 --- a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts @@ -42,6 +42,10 @@ export class ActionFactory< return this.def.getDisplayName(context); } + public getDisplayNameTooltip(context: FactoryContext): string { + return ''; + } + public async isCompatible(context: FactoryContext): Promise { if (!this.def.isCompatible) return true; return await this.def.isCompatible(context); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 0fbf0a5c7a27..3de725dc58ea 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -6,7 +6,21 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` Array [ Object { "field": "groupId", - "name": "Group ID", + "name": + Group ID + + + , "render": [Function], "sortable": false, "width": "96px", @@ -141,9 +155,26 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` > Group ID + + +
+
@@ -358,7 +389,21 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` Array [ Object { "field": "groupId", - "name": "Group ID", + "name": + Group ID + + + , "render": [Function], "sortable": false, "width": "96px", @@ -524,9 +569,26 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > Group ID + + +
+
@@ -689,6 +751,23 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" > Group ID + + +
+
List should render with data 1`] = ` className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" > Group ID + + +
+
List should render with data 1`] = ` className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" > Group ID + + +
+
List should render with data 1`] = ` className="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop" > Group ID + + +
+
= props => { if (!serviceName) { throw new Error('Service name is required'); } - const columns = useMemo( () => [ { - name: i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { - defaultMessage: 'Group ID' - }), + name: ( + <> + {i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { + defaultMessage: 'Group ID' + })}{' '} + + + ), field: 'groupId', sortable: false, width: px(unit * 6), diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx index cff190cd98a1..6aa7815ad688 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx @@ -134,9 +134,11 @@ export function MachineLearningFlyoutView({

+ ), + serviceMapAnnotationText: ( + + {i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.serviceMapAnnotationText', + { + defaultMessage: 'service maps' } )} @@ -155,15 +167,15 @@ export function MachineLearningFlyoutView({

{i18n.translate( 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.manageMLJobDescription.mlJobsPageLinkText', { - defaultMessage: 'Machine Learning jobs management page' + defaultMessage: 'Machine Learning Job Management page' } )} diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx index e3b33f11d080..f8dcec14630a 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { EuiToolTip, EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import styled from 'styled-components'; @@ -109,27 +109,26 @@ export function TransactionList({ items, isLoading }: Props) { { field: 'impact', name: ( - - <> - {i18n.translate('xpack.apm.transactionsTable.impactColumnLabel', { - defaultMessage: 'Impact' - })}{' '} - - - + <> + {i18n.translate('xpack.apm.transactionsTable.impactColumnLabel', { + defaultMessage: 'Impact' + })}{' '} + + ), sortable: true, dataType: 'number', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 4092e0148286..6d9a917af659 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -6,7 +6,8 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useMemo, useState } from 'react'; +import React, { FunctionComponent, useMemo, useState, MouseEvent } from 'react'; +import url from 'url'; import { Filter } from '../../../../common/custom_link/custom_link_types'; import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { @@ -82,7 +83,39 @@ export const TransactionActionMenu: FunctionComponent = ({ basePath: core.http.basePath, location, urlParams - }); + }).map(sectionList => + sectionList.map(section => ({ + ...section, + actions: section.actions.map(action => { + const { href } = action; + + // use navigateToApp as a temporary workaround for faster navigation between observability apps. + // see https://github.com/elastic/kibana/issues/65682 + + return { + ...action, + onClick: (event: MouseEvent) => { + const parsed = url.parse(href); + + const appPathname = core.http.basePath.remove( + parsed.pathname ?? '' + ); + + const [, , app, ...rest] = appPathname.split('/'); + + if (app === 'uptime' || app === 'metrics' || app === 'logs') { + event.preventDefault(); + core.application.navigateToApp(app, { + path: `${rest.join('/')}${ + parsed.search ? `&${parsed.search}` : '' + }` + }); + } + } + }; + }) + })) + ); const closePopover = () => { setIsActionPopoverOpen(false); @@ -151,6 +184,7 @@ export const TransactionActionMenu: FunctionComponent = ({ key={action.key} label={action.label} href={action.href} + onClick={action.onClick} /> ))} diff --git a/x-pack/plugins/canvas/common/lib/constants.ts b/x-pack/plugins/canvas/common/lib/constants.ts index a37dc3fd6a7b..f2155d920293 100644 --- a/x-pack/plugins/canvas/common/lib/constants.ts +++ b/x-pack/plugins/canvas/common/lib/constants.ts @@ -18,7 +18,7 @@ export const API_ROUTE_WORKPAD_STRUCTURES = `${API_ROUTE}/workpad-structures`; export const API_ROUTE_CUSTOM_ELEMENT = `${API_ROUTE}/custom-element`; export const LOCALSTORAGE_PREFIX = `kibana.canvas`; export const LOCALSTORAGE_CLIPBOARD = `${LOCALSTORAGE_PREFIX}.clipboard`; -export const LOCALSTORAGE_LASTPAGE = 'canvas:lastpage'; +export const SESSIONSTORAGE_LASTPATH = 'lastPath:canvas'; export const FETCH_TIMEOUT = 30000; // 30 seconds export const CANVAS_USAGE_TYPE = 'canvas'; export const DEFAULT_WORKPAD_CSS = '.canvasPage {\n\n}'; diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 284023e74d13..9c2aa821be2d 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -10,8 +10,9 @@ import ReactDOM from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { Provider } from 'react-redux'; +import { BehaviorSubject } from 'rxjs'; -import { AppMountParameters, CoreStart, CoreSetup } from 'kibana/public'; +import { AppMountParameters, CoreStart, CoreSetup, AppUpdater } from 'kibana/public'; import { CanvasStartDeps, CanvasSetupDeps } from './plugin'; // @ts-ignore Untyped local @@ -88,9 +89,10 @@ export const initializeCanvas = async ( coreStart: CoreStart, setupPlugins: CanvasSetupDeps, startPlugins: CanvasStartDeps, - registries: SetupRegistries + registries: SetupRegistries, + appUpdater: BehaviorSubject ) => { - startServices(coreSetup, coreStart, setupPlugins, startPlugins); + startServices(coreSetup, coreStart, setupPlugins, startPlugins, appUpdater); // Create Store const canvasStore = await createStore(coreSetup, setupPlugins); diff --git a/x-pack/plugins/canvas/public/components/app/index.js b/x-pack/plugins/canvas/public/components/app/index.js index de0d4c190eae..750132dadb97 100644 --- a/x-pack/plugins/canvas/public/components/app/index.js +++ b/x-pack/plugins/canvas/public/components/app/index.js @@ -8,6 +8,7 @@ import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { getAppReady, getBasePath } from '../../state/selectors/app'; import { appReady, appError } from '../../state/actions/app'; +import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; import { App as Component } from './app'; @@ -44,7 +45,8 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { export const App = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withProps(() => ({ - onRouteChange: () => undefined, + withKibana, + withProps(props => ({ + onRouteChange: props.kibana.services.canvas.navLink.updatePath, })) )(Component); diff --git a/x-pack/plugins/canvas/public/lib/clipboard.ts b/x-pack/plugins/canvas/public/lib/clipboard.ts index 11755807aa53..cb940fd064a4 100644 --- a/x-pack/plugins/canvas/public/lib/clipboard.ts +++ b/x-pack/plugins/canvas/public/lib/clipboard.ts @@ -4,22 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { LOCALSTORAGE_CLIPBOARD } from '../../common/lib/constants'; -import { getWindow } from './get_window'; - -let storage: Storage; - -const getStorage = (): Storage => { - if (!storage) { - storage = new Storage(getWindow().localStorage); - } - - return storage; -}; +import { getLocalStorage } from './storage'; export const setClipboardData = (data: any) => { - getStorage().set(LOCALSTORAGE_CLIPBOARD, JSON.stringify(data)); + getLocalStorage().set(LOCALSTORAGE_CLIPBOARD, JSON.stringify(data)); }; -export const getClipboardData = () => getStorage().get(LOCALSTORAGE_CLIPBOARD); +export const getClipboardData = () => getLocalStorage().get(LOCALSTORAGE_CLIPBOARD); diff --git a/x-pack/plugins/canvas/public/lib/get_window.ts b/x-pack/plugins/canvas/public/lib/get_window.ts index 42c632f4a514..c8fb035d4d33 100644 --- a/x-pack/plugins/canvas/public/lib/get_window.ts +++ b/x-pack/plugins/canvas/public/lib/get_window.ts @@ -5,10 +5,18 @@ */ // return window if it exists, otherwise just return an object literal -const windowObj = { location: null, localStorage: {} as Window['localStorage'] }; +const windowObj = { + location: null, + localStorage: {} as Window['localStorage'], + sessionStorage: {} as Window['sessionStorage'], +}; export const getWindow = (): | Window - | { location: Location | null; localStorage: Window['localStorage'] } => { + | { + location: Location | null; + localStorage: Window['localStorage']; + sessionStorage: Window['sessionStorage']; + } => { return typeof window === 'undefined' ? windowObj : window; }; diff --git a/x-pack/plugins/canvas/public/lib/storage.ts b/x-pack/plugins/canvas/public/lib/storage.ts new file mode 100644 index 000000000000..47c8cc741eaf --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/storage.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { getWindow } from './get_window'; + +export enum StorageType { + Local = 'localStorage', + Session = 'sessionStorage', +} + +const storages: { + [x in StorageType]: Storage | null; +} = { + [StorageType.Local]: null, + [StorageType.Session]: null, +}; + +const getStorage = (type: StorageType): Storage => { + const storage = storages[type] || new Storage(getWindow()[type]); + storages[type] = storage; + + return storage; +}; + +export const getLocalStorage = (): Storage => { + return getStorage(StorageType.Local); +}; + +export const getSessionStorage = (): Storage => { + return getStorage(StorageType.Session); +}; diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index ba57d1475bc4..c2192818e528 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { BehaviorSubject } from 'rxjs'; import { CoreSetup, CoreStart, Plugin, AppMountParameters, + AppUpdater, DEFAULT_APP_CATEGORIES, } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { initLoadingIndicator } from './lib/loading_indicator'; +import { getSessionStorage } from './lib/storage'; +import { SESSIONSTORAGE_LASTPATH } from '../common/lib/constants'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { ExpressionsSetup, ExpressionsStart } from '../../../../src/plugins/expressions/public'; import { DataPublicPluginSetup } from '../../../../src/plugins/data/public'; @@ -60,6 +64,7 @@ export type CanvasStart = void; /** @internal */ export class CanvasPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? private srcPlugin = new CanvasSrcPlugin(); @@ -68,12 +73,21 @@ export class CanvasPlugin this.srcPlugin.setup(core, { canvas: canvasApi }); + // Set the nav link to the last saved url if we have one in storage + const lastUrl = getSessionStorage().get(SESSIONSTORAGE_LASTPATH); + if (lastUrl) { + this.appUpdater.next(() => ({ + defaultPath: `#${lastUrl}`, + })); + } + core.application.register({ category: DEFAULT_APP_CATEGORIES.kibana, id: 'canvas', title: 'Canvas', euiIconType: 'canvasApp', order: 3000, + updater$: this.appUpdater, mount: async (params: AppMountParameters) => { // Load application bundle const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); @@ -81,7 +95,14 @@ export class CanvasPlugin // Get start services const [coreStart, depsStart] = await core.getStartServices(); - const canvasStore = await initializeCanvas(core, coreStart, plugins, depsStart, registries); + const canvasStore = await initializeCanvas( + core, + coreStart, + plugins, + depsStart, + registries, + this.appUpdater + ); const unmount = renderApp(coreStart, depsStart, params, canvasStore); diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index abc46beaa3e6..42176f953c33 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart } from '../../../../../src/core/public'; +import { BehaviorSubject } from 'rxjs'; +import { CoreSetup, CoreStart, AppUpdater } from '../../../../../src/core/public'; import { CanvasSetupDeps, CanvasStartDeps } from '../plugin'; import { notifyServiceFactory } from './notify'; import { platformServiceFactory } from './platform'; +import { navLinkServiceFactory } from './nav_link'; export type CanvasServiceFactory = ( coreSetup: CoreSetup, coreStart: CoreStart, canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject ) => Service; class CanvasServiceProvider { @@ -28,9 +31,16 @@ class CanvasServiceProvider { coreSetup: CoreSetup, coreStart: CoreStart, canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject ) { - this.service = this.factory(coreSetup, coreStart, canvasSetupPlugins, canvasStartPlugins); + this.service = this.factory( + coreSetup, + coreStart, + canvasSetupPlugins, + canvasStartPlugins, + appUpdater + ); } getService(): Service { @@ -51,20 +61,24 @@ export type ServiceFromProvider

= P extends CanvasServiceProvider ? export const services = { notify: new CanvasServiceProvider(notifyServiceFactory), platform: new CanvasServiceProvider(platformServiceFactory), + navLink: new CanvasServiceProvider(navLinkServiceFactory), }; export interface CanvasServices { notify: ServiceFromProvider; + platform: ServiceFromProvider; + navLink: ServiceFromProvider; } export const startServices = ( coreSetup: CoreSetup, coreStart: CoreStart, canvasSetupPlugins: CanvasSetupDeps, - canvasStartPlugins: CanvasStartDeps + canvasStartPlugins: CanvasStartDeps, + appUpdater: BehaviorSubject ) => { Object.entries(services).forEach(([key, provider]) => - provider.start(coreSetup, coreStart, canvasSetupPlugins, canvasStartPlugins) + provider.start(coreSetup, coreStart, canvasSetupPlugins, canvasStartPlugins, appUpdater) ); }; @@ -72,4 +86,8 @@ export const stopServices = () => { Object.entries(services).forEach(([key, provider]) => provider.stop()); }; -export const { notify: notifyService, platform: platformService } = services; +export const { + notify: notifyService, + platform: platformService, + navLink: navLinkService, +} = services; diff --git a/x-pack/plugins/canvas/public/services/nav_link.ts b/x-pack/plugins/canvas/public/services/nav_link.ts new file mode 100644 index 000000000000..506149845800 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/nav_link.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CanvasServiceFactory } from '.'; +import { SESSIONSTORAGE_LASTPATH } from '../../common/lib/constants'; +import { getSessionStorage } from '../lib/storage'; + +interface NavLinkService { + updatePath: (path: string) => void; +} + +export const navLinkServiceFactory: CanvasServiceFactory = ( + coreSetup, + coreStart, + setupPlugins, + startPlugins, + appUpdater +) => { + return { + updatePath: (path: string) => { + appUpdater.next(() => ({ + defaultPath: `#${path}`, + })); + + getSessionStorage().set(SESSIONSTORAGE_LASTPATH, path); + }, + }; +}; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index 6749b41e81fc..52c53f32ff09 100644 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -173,6 +173,42 @@ test('Create only mode', async () => { expect(await mockDynamicActionManager.state.get().events.length).toBe(1); }); +test('After switching between action factories state is restored', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + + // change to dashboard + fireEvent.click(screen.getByText(/change/i)); + fireEvent.click(screen.getByText(/Go to Dashboard/i)); + + // change back to url + fireEvent.click(screen.getByText(/change/i)); + fireEvent.click(screen.getByText(/Go to URL/i)); + + expect(screen.getByLabelText(/url/i)).toHaveValue('https://elastic.co'); + expect(screen.getByLabelText(/name/i)).toHaveValue('test'); + + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(await (mockDynamicActionManager.state.get().events[0].action.config as any).url).toBe( + 'https://elastic.co' + ); +}); + test.todo("Error when can't fetch drilldown list"); test("Error when can't save drilldown changes", async () => { diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index 8541aae06ff0..1f775a5ff103 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -41,6 +41,72 @@ export interface FlyoutDrilldownWizardProps void; + setActionConfig: (actionConfig: object) => void; + setActionFactory: (actionFactory?: ActionFactory) => void; + } +] { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + const [actionConfigCache, setActionConfigCache] = useState>( + initialDrilldownWizardConfig?.actionFactory + ? { + [initialDrilldownWizardConfig.actionFactory + .id]: initialDrilldownWizardConfig.actionConfig!, + } + : {} + ); + + return [ + wizardConfig, + { + setName: (name: string) => { + setWizardConfig({ + ...wizardConfig, + name, + }); + }, + setActionConfig: (actionConfig: object) => { + setWizardConfig({ + ...wizardConfig, + actionConfig, + }); + }, + setActionFactory: (actionFactory?: ActionFactory) => { + if (actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionConfigCache[actionFactory.id] ?? actionFactory.createConfig(), + }); + } else { + if (wizardConfig.actionFactory?.id) { + setActionConfigCache({ + ...actionConfigCache, + [wizardConfig.actionFactory.id]: wizardConfig.actionConfig!, + }); + } + + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } + }, + }, + ]; +} + export function FlyoutDrilldownWizard({ onClose, onBack, @@ -53,11 +119,8 @@ export function FlyoutDrilldownWizard) { - const [wizardConfig, setWizardConfig] = useState( - () => - initialDrilldownWizardConfig ?? { - name: '', - } + const [wizardConfig, { setActionFactory, setActionConfig, setName }] = useWizardConfigState( + initialDrilldownWizardConfig ); const isActionValid = ( @@ -95,35 +158,11 @@ export function FlyoutDrilldownWizard { - setWizardConfig({ - ...wizardConfig, - name: newName, - }); - }} + onNameChange={setName} actionConfig={wizardConfig.actionConfig} - onActionConfigChange={newActionConfig => { - setWizardConfig({ - ...wizardConfig, - actionConfig: newActionConfig, - }); - }} + onActionConfigChange={setActionConfig} currentActionFactory={wizardConfig.actionFactory} - onActionFactoryChange={actionFactory => { - if (!actionFactory) { - setWizardConfig({ - ...wizardConfig, - actionFactory: undefined, - actionConfig: undefined, - }); - } else { - setWizardConfig({ - ...wizardConfig, - actionFactory, - actionConfig: actionFactory.createConfig(), - }); - } - }} + onActionFactoryChange={setActionFactory} actionFactories={drilldownActionFactories} actionFactoryContext={actionFactoryContext!} /> diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx index 93b3710bf6cc..3bed81a97192 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -19,7 +19,7 @@ export interface FormDrilldownWizardProps { onNameChange?: (name: string) => void; currentActionFactory?: ActionFactory; - onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + onActionFactoryChange?: (actionFactory?: ActionFactory) => void; actionFactoryContext: object; actionConfig?: object; diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts index 839379387e09..158641cd9769 100644 --- a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts @@ -47,6 +47,40 @@ describe('PanelNotificationsAction', () => { }); }); + describe('getDisplayNameTooltip', () => { + test('returns empty string if embeddable has no event', async () => { + const context = createContext(); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayNameTooltip(context); + expect(name).toBe(''); + }); + + test('returns "1 drilldown" if embeddable has one event', async () => { + const context = createContext([{}]); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayNameTooltip(context); + expect(name).toBe('Panel has 1 drilldown'); + }); + + test('returns "2 drilldowns" if embeddable has two events', async () => { + const context = createContext([{}, {}]); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayNameTooltip(context); + expect(name).toBe('Panel has 2 drilldowns'); + }); + + test('returns "3 drilldowns" if embeddable has three events', async () => { + const context = createContext([{}, {}, {}]); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayNameTooltip(context); + expect(name).toBe('Panel has 3 drilldowns'); + }); + }); + describe('isCompatible', () => { test('returns false if not in "edit" mode', async () => { const context = createContext([{}]); diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts index 19e0ac2a5a6d..165ce24c13ea 100644 --- a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts @@ -4,10 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public'; import { ViewMode } from '../../../../../src/plugins/embeddable/public'; import { EnhancedEmbeddableContext, EnhancedEmbeddable } from '../types'; +export const txtOneDrilldown = i18n.translate( + 'xpack.embeddableEnhanced.actions.panelNotifications.oneDrilldown', + { + defaultMessage: 'Panel has 1 drilldown', + } +); + +export const txtManyDrilldowns = (count: number) => + i18n.translate('xpack.embeddableEnhanced.actions.panelNotifications.manyDrilldowns', { + defaultMessage: 'Panel has {count} drilldowns', + values: { + count: String(count), + }, + }); + export const ACTION_PANEL_NOTIFICATIONS = 'ACTION_PANEL_NOTIFICATIONS'; /** @@ -25,6 +41,11 @@ export class PanelNotificationsAction implements ActionDefinition { + const count = this.getEventCount(embeddable); + return !count ? '' : count === 1 ? txtOneDrilldown : txtManyDrilldowns(count); + }; + public readonly isCompatible = async ({ embeddable }: EnhancedEmbeddableContext) => { if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; return this.getEventCount(embeddable) > 0; diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index 9e7aedcc90bb..ff8add42a508 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -560,7 +560,7 @@ export class EndpointDocGenerator { applied: { actions: { configure_elasticsearch_connection: { - message: 'elasticsearch comes configured successfully', + message: 'elasticsearch communications configured successfully', status: HostPolicyResponseActionStatus.success, }, configure_kernel: { diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 181b0e7ab388..b39b2e89ee15 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -644,6 +644,9 @@ export interface HostPolicyResponseActions { read_malware_config: HostPolicyResponseActionDetails; } +/** + * policy configurations returned by the endpoint in response to a user applying a policy + */ export type HostPolicyResponseConfiguration = HostPolicyResponse['endpoint']['policy']['applied']['response']['configurations']; interface HostPolicyResponseConfigurationStatus { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx index aa04f2fdff57..8714141364e7 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response.tsx @@ -12,7 +12,6 @@ import { HostPolicyResponseActions, HostPolicyResponseConfiguration, Immutable, - ImmutableArray, } from '../../../../../../common/types'; import { formatResponse } from './policy_response_friendly_names'; import { POLICY_STATUS_TO_HEALTH_COLOR } from './host_constants'; @@ -51,7 +50,7 @@ const ResponseActions = memo( actions, actionStatus, }: { - actions: ImmutableArray; + actions: Immutable>; actionStatus: Partial; }) => { return ( diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts index 251b3e86bc3f..502aa66b2442 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/hosts/details/policy_response_friendly_names.ts @@ -159,8 +159,7 @@ responseMap.set( ); /** - * Takes in the snake-cased response from the API and - * removes the underscores and capitalizes the string. + * Maps a server provided value to corresponding i18n'd string. */ export function formatResponse(responseString: string) { if (responseMap.has(responseString)) { diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 855565859617..939d92518e27 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -122,8 +122,8 @@ diff --git a/x-pack/plugins/index_management/__jest__/components/index_table.test.js b/x-pack/plugins/index_management/__jest__/components/index_table.test.js index 15c3ef0b8456..84fbc04aa5a3 100644 --- a/x-pack/plugins/index_management/__jest__/components/index_table.test.js +++ b/x-pack/plugins/index_management/__jest__/components/index_table.test.js @@ -8,6 +8,15 @@ import React from 'react'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { MemoryRouter } from 'react-router-dom'; + +/** + * The below import is required to avoid a console error warn from brace package + * console.warn ../node_modules/brace/index.js:3999 + Could not load worker ReferenceError: Worker is not defined + at createWorker (//node_modules/brace/index.js:17992:5) + */ +import * as stubWebWorker from '../../../../test_utils/stub_web_worker'; // eslint-disable-line no-unused-vars + import { AppWithoutRouter } from '../../public/application/app'; import { AppContextProvider } from '../../public/application/app_context'; import { Provider } from 'react-redux'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts new file mode 100644 index 000000000000..eac68770d3de --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { defaultShapeParameters } from './shape_datatype.test'; +export { defaultTextParameters } from './text_datatype.test'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx new file mode 100644 index 000000000000..19bf6973472f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +// Parameters automatically added to the shape datatype when saved (with the default values) +export const defaultShapeParameters = { + type: 'shape', + coerce: false, + ignore_malformed: false, + ignore_z_value: true, +}; + +describe('Mappings editor: shape datatype', () => { + let testBed: MappingsEditorTestBed; + + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + test('initial view and default parameters values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'shape', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + exists, + waitFor, + waitForFn, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // Save the field and close the flyout + await act(async () => { + await updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + // It should have the default parameters values added + updatedMappings.properties.myField = { + type: 'shape', + ...defaultShapeParameters, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx new file mode 100644 index 000000000000..2bfaa884a013 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx @@ -0,0 +1,459 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; +import { getFieldConfig } from '../../../lib'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +// Parameters automatically added to the text datatype when saved (with the default values) +export const defaultTextParameters = { + type: 'text', + eager_global_ordinals: false, + fielddata: false, + index: true, + index_options: 'positions', + index_phrases: false, + norms: true, + store: false, +}; + +describe('Mappings editor: text datatype', () => { + let testBed: MappingsEditorTestBed; + + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('initial view and default parameters values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + exists, + waitFor, + waitForFn, + actions: { startEditField, getToggleValue, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // It should have searchable ("index" param) active by default + const indexFieldConfig = getFieldConfig('index'); + expect(getToggleValue('indexParameter.formRowToggle')).toBe(indexFieldConfig.defaultValue); + + // Save the field and close the flyout + await act(async () => { + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + // It should have the default parameters values added + updatedMappings.properties.myField = { + type: 'text', + ...defaultTextParameters, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + }, 30000); + + test('analyzer parameter: default values', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + // Should have 2 dropdown selects: + // The first one set to 'language' and the second one set to 'french + search_quote_analyzer: 'french', + }, + }, + }; + + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + + const { + find, + exists, + waitFor, + waitForFn, + form: { selectCheckBox, setSelectValue }, + actions: { + startEditField, + getCheckboxValue, + showAdvancedSettings, + updateFieldAndCloseFlyout, + }, + } = testBed; + const fieldToEdit = 'myField'; + + // Start edit and immediately save to have all the default values + await act(async () => { + startEditField(fieldToEdit); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + await act(async () => { + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + expect(data).toEqual(updatedMappings); + + // Re-open the edit panel + await act(async () => { + startEditField('myField'); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + // When no analyzer is defined, defaults to "Index default" + let indexAnalyzerValue = find('indexAnalyzer.select').props().value; + expect(indexAnalyzerValue).toEqual('index_default'); + + const searchQuoteAnalyzerSelects = find('searchQuoteAnalyzer.select'); + + expect(searchQuoteAnalyzerSelects.length).toBe(2); + expect(searchQuoteAnalyzerSelects.at(0).props().value).toBe('language'); + expect(searchQuoteAnalyzerSelects.at(1).props().value).toBe( + defaultMappings.properties.myField.search_quote_analyzer + ); + + // When no "search_analyzer" is defined, the checkBox should be checked + let isUseSameAnalyzerForSearchChecked = getCheckboxValue( + 'useSameAnalyzerForSearchCheckBox.input' + ); + expect(isUseSameAnalyzerForSearchChecked).toBe(true); + + // And the search analyzer select should not exist + expect(exists('searchAnalyzer')).toBe(false); + + // Uncheck the "Use same analyzer for search" checkbox and wait for the search analyzer select + await act(async () => { + selectCheckBox('useSameAnalyzerForSearchCheckBox.input', false); + }); + + await waitFor('searchAnalyzer'); + + let searchAnalyzerValue = find('searchAnalyzer.select').props().value; + expect(searchAnalyzerValue).toEqual('index_default'); + + await act(async () => { + // Change the value of the 3 analyzers + setSelectValue('indexAnalyzer.select', 'standard'); + setSelectValue('searchAnalyzer.select', 'simple'); + setSelectValue(find('searchQuoteAnalyzer.select').at(0), 'whitespace'); + }); + + // Make sure the second dropdown select has been removed + await waitForFn( + async () => find('searchQuoteAnalyzer.select').length === 1, + 'Error waiting for the second dropdown select of search quote analyzer to be removed' + ); + + await act(async () => { + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: 'standard', + search_analyzer: 'simple', + search_quote_analyzer: 'whitespace', + }, + }, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + // Re-open the flyout and make sure the select have the correct updated value + await act(async () => { + startEditField('myField'); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + isUseSameAnalyzerForSearchChecked = getCheckboxValue('useSameAnalyzerForSearchCheckBox.input'); + expect(isUseSameAnalyzerForSearchChecked).toBe(false); + + indexAnalyzerValue = find('indexAnalyzer.select').props().value; + searchAnalyzerValue = find('searchAnalyzer.select').props().value; + const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer.select').props().value; + + expect(indexAnalyzerValue).toBe('standard'); + expect(searchAnalyzerValue).toBe('simple'); + expect(searchQuoteAnalyzerValue).toBe('whitespace'); + }, 30000); + + test('analyzer parameter: custom analyzer (external plugin)', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + analyzer: 'myCustomIndexAnalyzer', + search_analyzer: 'myCustomSearchAnalyzer', + search_quote_analyzer: 'myCustomSearchQuoteAnalyzer', + }, + }, + }; + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + find, + exists, + waitFor, + waitForFn, + component, + form: { setInputValue, setSelectValue }, + actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout }, + } = testBed; + const fieldToEdit = 'myField'; + + await act(async () => { + startEditField(fieldToEdit); + }); + + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + expect(exists('indexAnalyzer-custom')).toBe(true); + expect(exists('searchAnalyzer-custom')).toBe(true); + expect(exists('searchQuoteAnalyzer-custom')).toBe(true); + + const indexAnalyzerValue = find('indexAnalyzer-custom.input').props().value; + const searchAnalyzerValue = find('searchAnalyzer-custom.input').props().value; + const searchQuoteAnalyzerValue = find('searchQuoteAnalyzer-custom.input').props().value; + + expect(indexAnalyzerValue).toBe(defaultMappings.properties.myField.analyzer); + expect(searchAnalyzerValue).toBe(defaultMappings.properties.myField.search_analyzer); + expect(searchQuoteAnalyzerValue).toBe(defaultMappings.properties.myField.search_quote_analyzer); + + const updatedIndexAnalyzer = 'newCustomIndexAnalyzer'; + const updatedSearchAnalyzer = 'whitespace'; + + await act(async () => { + // Change the index analyzer to another custom one + setInputValue('indexAnalyzer-custom.input', updatedIndexAnalyzer); + + // Change the search analyzer to a built-in analyzer + find('searchAnalyzer-toggleCustomButton').simulate('click'); + component.update(); + }); + + await waitFor('searchAnalyzer'); + + await act(async () => { + setSelectValue('searchAnalyzer.select', updatedSearchAnalyzer); + + // Change the searchQuote to use built-in analyzer + // By default it means using the "index default" + find('searchQuoteAnalyzer-toggleCustomButton').simulate('click'); + component.update(); + }); + + await waitFor('searchQuoteAnalyzer'); + + await act(async () => { + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: updatedIndexAnalyzer, + search_analyzer: updatedSearchAnalyzer, + search_quote_analyzer: undefined, // Index default means not declaring the analyzer + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 30000); + + test('analyzer parameter: custom analyzer (from index settings)', async () => { + const indexSettings = { + analysis: { + analyzer: { + customAnalyzer_1: {}, + customAnalyzer_2: {}, + customAnalyzer_3: {}, + }, + }, + }; + + const customAnalyzers = Object.keys(indexSettings.analysis.analyzer); + + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + type: 'text', + analyzer: customAnalyzers[0], + }, + }, + }; + + let updatedMappings: any = { + ...defaultMappings, + properties: { + myField: { + ...defaultMappings.properties.myField, + ...defaultTextParameters, + }, + }, + }; + + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + indexSettings, + }); + + const { + find, + exists, + waitFor, + waitForFn, + form: { setSelectValue }, + actions: { startEditField, showAdvancedSettings, updateFieldAndCloseFlyout }, + } = testBed; + const fieldToEdit = 'myField'; + + await act(async () => { + startEditField(fieldToEdit); + }); + await waitFor('mappingsEditorFieldEdit'); + await showAdvancedSettings(); + + // It should have 2 selects + const indexAnalyzerSelects = find('indexAnalyzer.select'); + + expect(indexAnalyzerSelects.length).toBe(2); + expect(indexAnalyzerSelects.at(0).props().value).toBe('custom'); + expect(indexAnalyzerSelects.at(1).props().value).toBe( + defaultMappings.properties.myField.analyzer + ); + + // Access the list of option of the second dropdown select + const subSelectOptions = indexAnalyzerSelects + .at(1) + .find('option') + .map(wrapper => wrapper.text()); + + expect(subSelectOptions).toEqual(customAnalyzers); + + await act(async () => { + // Change the custom analyzer dropdown to another one from the index settings + setSelectValue(find('indexAnalyzer.select').at(1), customAnalyzers[2]); + + // Save & close + updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + ({ data } = await getMappingsEditorData()); + + updatedMappings = { + ...updatedMappings, + properties: { + myField: { + ...updatedMappings.properties.myField, + analyzer: customAnalyzers[2], + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 30000); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx new file mode 100644 index 000000000000..4af5f82d851e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from './helpers'; +import { defaultTextParameters, defaultShapeParameters } from './datatypes'; +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +describe('Mappings editor: edit field', () => { + let testBed: MappingsEditorTestBed; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('should open a flyout with the correct field to edit', async () => { + const defaultMappings = { + properties: { + user: { + type: 'object', + properties: { + address: { + type: 'object', + properties: { + street: { type: 'text' }, + }, + }, + }, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + // Make sure all the fields are expanded and present in the DOM + await testBed.actions.expandAllFieldsAndReturnMetadata(); + }); + + const { + find, + waitFor, + actions: { startEditField }, + } = testBed; + // Open the flyout to edit the field + await act(async () => { + startEditField('user.address.street'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + // It should have the correct title + expect(find('mappingsEditorFieldEdit.flyoutTitle').text()).toEqual(`Edit field 'street'`); + + // It should have the correct field path + expect(find('mappingsEditorFieldEdit.fieldPath').text()).toEqual('user > address > street'); + + // The advanced settings should be hidden initially + expect(find('mappingsEditorFieldEdit.advancedSettings').props().style.display).toEqual('none'); + }); + + test('should update form parameters when changing the field datatype', async () => { + const defaultMappings = { + _meta: {}, + _source: {}, + properties: { + myField: { + ...defaultTextParameters, + }, + }, + }; + + await act(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + const { + find, + exists, + waitFor, + waitForFn, + component, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout, change the field type and save it + await act(async () => { + startEditField('myField'); + }); + + await waitFor('mappingsEditorFieldEdit'); + + await act(async () => { + // Change the field type + find('mappingsEditorFieldEdit.fieldType').simulate('change', [ + { label: 'Shape', value: defaultShapeParameters.type }, + ]); + component.update(); + }); + + await act(async () => { + await updateFieldAndCloseFlyout(); + }); + + await waitForFn( + async () => exists('mappingsEditorFieldEdit') === false, + 'Error waiting for the details flyout to close' + ); + + const { data } = await getMappingsEditorData(); + + const updatedMappings = { + ...defaultMappings, + properties: { + myField: { + ...defaultShapeParameters, + }, + }, + }; + + expect(data).toEqual(updatedMappings); + }, 15000); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts index fa6bee56349e..afdc039ae77d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts @@ -3,7 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { setup as mappingsEditorSetup, MappingsEditorTestBed } from './mappings_editor.helpers'; +import { + setup as mappingsEditorSetup, + MappingsEditorTestBed, + DomFields, + getMappingsEditorDataFactory, +} from './mappings_editor.helpers'; export { nextTick, @@ -13,7 +18,7 @@ export { } from '../../../../../../../../../test_utils'; export const componentHelpers = { - mappingsEditor: { setup: mappingsEditorSetup }, + mappingsEditor: { setup: mappingsEditorSetup, getMappingsEditorDataFactory }, }; -export { MappingsEditorTestBed }; +export { MappingsEditorTestBed, DomFields }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index c8c8ef8bfe9b..58242ec35018 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; + import { registerTestBed, TestBed, nextTick } from '../../../../../../../../../test_utils'; +import { getChildFieldsName } from '../../../lib'; import { MappingsEditor } from '../../../mappings_editor'; jest.mock('@elastic/eui', () => ({ @@ -14,6 +18,7 @@ jest.mock('@elastic/eui', () => ({ EuiComboBox: (props: any) => ( { props.onChange([syntheticEvent['0']]); }} @@ -29,14 +34,121 @@ jest.mock('@elastic/eui', () => ({ }} /> ), + // Mocking EuiSuperSelect to be able to easily change its value + // with a `myWrapper.simulate('change', { target: { value: 'someValue' } })` + EuiSuperSelect: (props: any) => ( + { + props.onChange(e.target.value); + }} + /> + ), })); +export interface DomFields { + [key: string]: { + type: string; + properties?: DomFields; + fields?: DomFields; + }; +} + const createActions = (testBed: TestBed) => { - const { find, waitFor, form, component } = testBed; + const { find, exists, waitFor, waitForFn, form, component } = testBed; + + const getFieldInfo = (testSubjectField: string): { name: string; type: string } => { + const name = find(`${testSubjectField}-fieldName` as TestSubjects).text(); + const type = find(`${testSubjectField}-datatype` as TestSubjects).props()['data-type-value']; + return { name, type }; + }; + + const expandField = async ( + field: ReactWrapper + ): Promise<{ hasChildren: boolean; testSubjectField: string }> => { + /** + * Field list item have 2 test subject assigned to them: + * data-test-subj="fieldsListItem " + * + * We read the second one as it is unique. + */ + const testSubjectField = (field.props() as any)['data-test-subj'] + .split(' ') + .filter((subj: string) => subj !== 'fieldsListItem')[0] as string; + + const expandButton = find(`${testSubjectField}.toggleExpandButton` as TestSubjects); + + // No expand button, so this field is not expanded + if (expandButton.length === 0) { + return { hasChildren: false, testSubjectField }; + } + + const isExpanded = (expandButton.props()['aria-label'] as string).includes('Collapse'); + + if (!isExpanded) { + expandButton.simulate('click'); + } + + // Wait for the children FieldList to be in the DOM + await waitFor(`${testSubjectField}.fieldsList` as TestSubjects); + + return { hasChildren: true, testSubjectField }; + }; + + /** + * Expand all the children of a field and return a metadata object of the fields found in the DOM. + * + * @param fieldName The field under wich we want to expand all the children. + * If no fieldName is provided, we expand all the **root** level fields. + */ + const expandAllFieldsAndReturnMetadata = async ( + fieldName?: string, + domTreeMetadata: DomFields = {} + ): Promise => { + const fields = find( + fieldName ? (`${fieldName}.fieldsList.fieldsListItem` as TestSubjects) : 'fieldsListItem' + ).map(wrapper => wrapper); // convert to Array for our for of loop below + + for (const field of fields) { + const { hasChildren, testSubjectField } = await expandField(field); + + // Read the info from the DOM about that field and add it to our domFieldMeta + const { name, type } = getFieldInfo(testSubjectField); + domTreeMetadata[name] = { + type, + }; + + if (hasChildren) { + // Update our metadata object + const childFieldName = getChildFieldsName(type as any)!; + domTreeMetadata[name][childFieldName] = {}; + + // Expand its children + await expandAllFieldsAndReturnMetadata( + testSubjectField, + domTreeMetadata[name][childFieldName] + ); + } + } + + return domTreeMetadata; + }; + + // Get a nested field in the rendered DOM tree + const getFieldAt = (path: string) => { + const testSubjectField = `${path.split('.').join('')}Field`; + return find(testSubjectField as TestSubjects); + }; const addField = async (name: string, type: string) => { const currentCount = find('fieldsListItem').length; + if (!exists('createFieldForm')) { + find('addFieldButton').simulate('click'); + await waitFor('createFieldForm'); + } + form.setInputValue('nameParameterInput', name); find('createFieldForm.fieldType').simulate('change', [ { @@ -54,6 +166,36 @@ const createActions = (testBed: TestBed) => { await waitFor('fieldsListItem', currentCount + 1); }; + const startEditField = (path: string) => { + const field = getFieldAt(path); + find('editFieldButton', field).simulate('click'); + component.update(); + }; + + const updateFieldAndCloseFlyout = () => { + find('mappingsEditorFieldEdit.editFieldUpdateButton').simulate('click'); + component.update(); + }; + + const showAdvancedSettings = async () => { + const checkIsVisible = async () => + find('mappingsEditorFieldEdit.advancedSettings').props().style.display === 'block'; + + if (await checkIsVisible()) { + // Already opened, nothing else to do + return; + } + + await act(async () => { + find('mappingsEditorFieldEdit.toggleAdvancedSetting').simulate('click'); + }); + + await waitForFn( + checkIsVisible, + 'Error waiting for the advanced settings CSS style.display to be "block"' + ); + }; + const selectTab = async (tab: 'fields' | 'templates' | 'advanced') => { const index = ['fields', 'templates', 'advanced'].indexOf(tab); const tabIdToContentMap: { [key: string]: TestSubjects } = { @@ -87,11 +229,33 @@ const createActions = (testBed: TestBed) => { return value; }; + const getComboBoxValue = (testSubject: TestSubjects) => { + const value = find(testSubject).props()['data-currentvalue']; + if (value === undefined) { + return []; + } + return value.map(({ label }: any) => label); + }; + + const getToggleValue = (testSubject: TestSubjects): boolean => + find(testSubject).props()['aria-checked']; + + const getCheckboxValue = (testSubject: TestSubjects): boolean => + find(testSubject).props().checked; + return { selectTab, + getFieldAt, addField, + expandAllFieldsAndReturnMetadata, + startEditField, + updateFieldAndCloseFlyout, + showAdvancedSettings, updateJsonEditor, getJsonEditorValue, + getComboBoxValue, + getToggleValue, + getCheckboxValue, }; }; @@ -109,6 +273,33 @@ export const setup = async (props: any = { onUpdate() {} }): Promise) => { + /** + * Helper to access the latest data sent to the onChange handler back to the consumer of the . + * Read the latest call with its argument passed and build the mappings object from it. + */ + return async () => { + const mockCalls = onChangeHandler.mock.calls; + + if (mockCalls.length === 0) { + throw new Error( + `Can't access data forwarded as the onChange() prop handler hasn't been called.` + ); + } + + const [arg] = mockCalls[mockCalls.length - 1]; + const { isValid, validate, getData } = arg; + + const isMappingsValid = isValid === undefined ? await act(validate) : isValid; + const data = getData(isMappingsValid); + + return { + isValid: isMappingsValid, + data, + }; + }; +}; + export type MappingsEditorTestBed = TestBed & { actions: ReturnType; }; @@ -116,7 +307,9 @@ export type MappingsEditorTestBed = TestBed & { export type TestSubjects = | 'formTab' | 'mappingsEditor' + | 'fieldsList' | 'fieldsListItem' + | 'fieldsListItem.fieldName' | 'fieldName' | 'mappingTypesDetectedCallout' | 'documentFields' @@ -126,7 +319,38 @@ export type TestSubjects = | 'advancedConfiguration.numericDetection.input' | 'advancedConfiguration.dynamicMappingsToggle' | 'advancedConfiguration.dynamicMappingsToggle.input' + | 'advancedConfiguration.metaField' + | 'advancedConfiguration.routingRequiredToggle.input' + | 'sourceField.includesField' + | 'sourceField.excludesField' | 'dynamicTemplatesEditor' | 'nameParameterInput' + | 'addFieldButton' + | 'editFieldButton' + | 'toggleExpandButton' + | 'createFieldForm' | 'createFieldForm.fieldType' - | 'createFieldForm.addButton'; + | 'createFieldForm.addButton' + | 'mappingsEditorFieldEdit' + | 'mappingsEditorFieldEdit.fieldType' + | 'mappingsEditorFieldEdit.editFieldUpdateButton' + | 'mappingsEditorFieldEdit.flyoutTitle' + | 'mappingsEditorFieldEdit.documentationLink' + | 'mappingsEditorFieldEdit.fieldPath' + | 'mappingsEditorFieldEdit.advancedSettings' + | 'mappingsEditorFieldEdit.toggleAdvancedSetting' + | 'indexParameter.formRowToggle' + | 'indexAnalyzer.select' + | 'searchAnalyzer' + | 'searchAnalyzer.select' + | 'searchQuoteAnalyzer' + | 'searchQuoteAnalyzer.select' + | 'indexAnalyzer-custom' + | 'indexAnalyzer-custom.input' + | 'searchAnalyzer-toggleCustomButton' + | 'searchAnalyzer-custom' + | 'searchAnalyzer-custom.input' + | 'searchQuoteAnalyzer-custom' + | 'searchQuoteAnalyzer-toggleCustomButton' + | 'searchQuoteAnalyzer-custom.input' + | 'useSameAnalyzerForSearchCheckBox.input'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx new file mode 100644 index 000000000000..8989e85d9f18 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed, DomFields, nextTick } from './helpers'; + +const { setup } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); + +describe('Mappings editor: mapped fields', () => { + afterEach(() => { + onChangeHandler.mockReset(); + }); + + describe('', () => { + let testBed: MappingsEditorTestBed; + const defaultMappings = { + properties: { + myField: { + type: 'text', + fields: { + raw: { + type: 'keyword', + }, + simpleAnalyzer: { + type: 'text', + }, + }, + }, + myObject: { + type: 'object', + properties: { + deeplyNested: { + type: 'object', + properties: { + title: { + type: 'text', + fields: { + raw: { type: 'keyword' }, + }, + }, + }, + }, + }, + }, + }, + }; + + test('should correctly represent the fields in the DOM tree', async () => { + await act(async () => { + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + }); + + const { + actions: { expandAllFieldsAndReturnMetadata }, + } = testBed; + + let domTreeMetadata: DomFields = {}; + await act(async () => { + domTreeMetadata = await expandAllFieldsAndReturnMetadata(); + }); + + expect(domTreeMetadata).toEqual(defaultMappings.properties); + }); + + test('should allow to be controlled by parent component and update on prop change', async () => { + await act(async () => { + testBed = await setup({ + value: defaultMappings, + onChange: onChangeHandler, + }); + }); + + const { + component, + setProps, + actions: { expandAllFieldsAndReturnMetadata }, + } = testBed; + + const newMappings = { properties: { hello: { type: 'text' } } }; + let domTreeMetadata: DomFields = {}; + + await act(async () => { + // Change the `value` prop of our + setProps({ value: newMappings }); + + // Don't ask me why but the 3 following lines are all required + component.update(); + await nextTick(); + component.update(); + + domTreeMetadata = await expandAllFieldsAndReturnMetadata(); + }); + + expect(domTreeMetadata).toEqual(newMappings.properties); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx index 0cf5bf3f4453..f516dfdb372c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mappings_editor.test.tsx @@ -5,15 +5,55 @@ */ import { act } from 'react-dom/test-utils'; -import { componentHelpers, MappingsEditorTestBed, nextTick, getRandomString } from './helpers'; +import { componentHelpers, MappingsEditorTestBed, nextTick } from './helpers'; -const { setup } = componentHelpers.mappingsEditor; -const mockOnUpdate = () => undefined; +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; +const onChangeHandler = jest.fn(); +const getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + +describe('Mappings editor: core', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + + afterEach(() => { + onChangeHandler.mockReset(); + }); + + test('default behaviour', async () => { + const defaultMappings = { + properties: { + user: { + // No type defined for user + properties: { + name: { type: 'text' }, + }, + }, + }, + }; + + await setup({ value: defaultMappings, onChange: onChangeHandler }); + + const expectedMappings = { + _meta: {}, // Was not defined so an empty object is returned + _source: {}, // Was not defined so an empty object is returned + ...defaultMappings, + properties: { + user: { + type: 'object', // Was not defined so it defaults to "object" type + ...defaultMappings.properties.user, + }, + }, + }; + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(expectedMappings); + }); -describe('', () => { describe('multiple mappings detection', () => { test('should show a warning when multiple mappings are detected', async () => { - const defaultValue = { + const value = { type1: { properties: { name1: { @@ -29,7 +69,7 @@ describe('', () => { }, }, }; - const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue }); + const testBed = await setup({ onChange: onChangeHandler, value }); const { exists } = testBed; expect(exists('mappingsEditor')).toBe(true); @@ -38,14 +78,14 @@ describe('', () => { }); test('should not show a warning when mappings a single-type', async () => { - const defaultValue = { + const value = { properties: { name1: { type: 'keyword', }, }, }; - const testBed = await setup({ onUpdate: mockOnUpdate, defaultValue }); + const testBed = await setup({ onChange: onChangeHandler, value }); const { exists } = testBed; expect(exists('mappingsEditor')).toBe(true); @@ -62,12 +102,12 @@ describe('', () => { let testBed: MappingsEditorTestBed; beforeEach(async () => { - testBed = await setup({ defaultValue: defaultMappings, onUpdate() {} }); + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); }); test('should keep the changes when switching tabs', async () => { const { - actions: { addField, selectTab, updateJsonEditor, getJsonEditorValue }, + actions: { addField, selectTab, updateJsonEditor, getJsonEditorValue, getToggleValue }, component, find, exists, @@ -79,7 +119,7 @@ describe('', () => { // ------------------------------------- expect(find('fieldsListItem').length).toEqual(0); // Check that we start with an empty list - const newField = { name: getRandomString(), type: 'text' }; + const newField = { name: 'John', type: 'text' }; await act(async () => { await addField(newField.name, newField.type); }); @@ -101,7 +141,6 @@ describe('', () => { // Update the dynamic templates editor value const updatedValueTemplates = [{ after: 'bar' }]; - await act(async () => { await updateJsonEditor('dynamicTemplatesEditor', updatedValueTemplates); await nextTick(); @@ -118,9 +157,9 @@ describe('', () => { await selectTab('advanced'); }); - let isDynamicMappingsEnabled = find( + let isDynamicMappingsEnabled = getToggleValue( 'advancedConfiguration.dynamicMappingsToggle.input' - ).props()['aria-checked']; + ); expect(isDynamicMappingsEnabled).toBe(true); let isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); @@ -134,9 +173,9 @@ describe('', () => { await nextTick(); }); - isDynamicMappingsEnabled = find('advancedConfiguration.dynamicMappingsToggle.input').props()[ - 'aria-checked' - ]; + isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); expect(isDynamicMappingsEnabled).toBe(false); isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); @@ -166,12 +205,185 @@ describe('', () => { await selectTab('advanced'); }); - isDynamicMappingsEnabled = find('advancedConfiguration.dynamicMappingsToggle.input').props()[ - 'aria-checked' - ]; + isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); expect(isDynamicMappingsEnabled).toBe(false); isNumericDetectionVisible = exists('advancedConfiguration.numericDetection'); expect(isNumericDetectionVisible).toBe(false); }); }); + + describe('component props', () => { + /** + * Note: the "indexSettings" prop will be tested along with the "analyzer" parameter on a text datatype field, + * as it is the only place where it is consumed by the mappings editor. + * + * The test that covers it is text_datatype.test.tsx: "analyzer parameter: custom analyzer (from index settings)" + */ + const defaultMappings: any = { + dynamic: true, + numeric_detection: false, + date_detection: true, + properties: { + title: { type: 'text' }, + address: { + type: 'object', + properties: { + street: { type: 'text' }, + city: { type: 'text' }, + }, + }, + }, + dynamic_templates: [{ initial: 'value' }], + _source: { + enabled: true, + includes: ['field1', 'field2'], + excludes: ['field3'], + }, + _meta: { + some: 'metaData', + }, + _routing: { + required: false, + }, + }; + + let testBed: MappingsEditorTestBed; + + beforeEach(async () => { + testBed = await setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + + test('props.value => should prepopulate the editor data', async () => { + const { + actions: { selectTab, getJsonEditorValue, getComboBoxValue, getToggleValue }, + find, + } = testBed; + + /** + * Mapped fields + */ + // Test that root-level mappings "properties" are rendered as root-level "DOM tree items" + const fields = find('fieldsListItem.fieldName').map(item => item.text()); + expect(fields).toEqual(Object.keys(defaultMappings.properties).sort()); + + /** + * Dynamic templates + */ + await act(async () => { + await selectTab('templates'); + }); + + // Test that dynamic templates JSON is rendered in the templates editor + const templatesValue = getJsonEditorValue('dynamicTemplatesEditor'); + expect(templatesValue).toEqual(defaultMappings.dynamic_templates); + + /** + * Advanced settings + */ + await act(async () => { + await selectTab('advanced'); + }); + + const isDynamicMappingsEnabled = getToggleValue( + 'advancedConfiguration.dynamicMappingsToggle.input' + ); + expect(isDynamicMappingsEnabled).toBe(defaultMappings.dynamic); + + const isNumericDetectionEnabled = getToggleValue( + 'advancedConfiguration.numericDetection.input' + ); + expect(isNumericDetectionEnabled).toBe(defaultMappings.numeric_detection); + + expect(getComboBoxValue('sourceField.includesField')).toEqual( + defaultMappings._source.includes + ); + expect(getComboBoxValue('sourceField.excludesField')).toEqual( + defaultMappings._source.excludes + ); + + const metaFieldValue = getJsonEditorValue('advancedConfiguration.metaField'); + expect(metaFieldValue).toEqual(defaultMappings._meta); + + const isRoutingRequired = getToggleValue('advancedConfiguration.routingRequiredToggle.input'); + expect(isRoutingRequired).toBe(defaultMappings._routing.required); + }); + + test('props.onChange() => should forward the changes to the consumer component', async () => { + let updatedMappings = { ...defaultMappings }; + + const { + actions: { addField, selectTab, updateJsonEditor }, + component, + form, + } = testBed; + + /** + * Mapped fields + */ + const newField = { name: 'someNewField', type: 'text' }; + updatedMappings = { + ...updatedMappings, + properties: { + ...updatedMappings.properties, + [newField.name]: { type: 'text' }, + }, + }; + + await act(async () => { + await addField(newField.name, newField.type); + }); + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + /** + * Dynamic templates + */ + await act(async () => { + await selectTab('templates'); + }); + + const updatedTemplatesValue = [{ someTemplateProp: 'updated' }]; + updatedMappings = { + ...updatedMappings, + dynamic_templates: updatedTemplatesValue, + }; + + await act(async () => { + await updateJsonEditor('dynamicTemplatesEditor', updatedTemplatesValue); + await nextTick(); + component.update(); + }); + + ({ data } = await getMappingsEditorData()); + expect(data).toEqual(updatedMappings); + + /** + * Advanced settings + */ + await act(async () => { + await selectTab('advanced'); + }); + + // Disbable dynamic mappings + await act(async () => { + form.toggleEuiSwitch('advancedConfiguration.dynamicMappingsToggle.input'); + }); + + ({ data } = await getMappingsEditorData()); + + // When we disable dynamic mappings, we set it to "false" and remove date and numeric detections + updatedMappings = { + ...updatedMappings, + dynamic: false, + date_detection: undefined, + dynamic_date_formats: undefined, + numeric_detection: undefined, + }; + + expect(data).toEqual(updatedMappings); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 6b33d4450c3a..c84756cab8e8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc } from '../../shared_imports'; +import { GenericObject } from '../../types'; import { Types, useDispatch } from '../../mappings_state'; import { DynamicMappingSection } from './dynamic_mapping_section'; import { SourceFieldSection } from './source_field_section'; @@ -17,10 +18,10 @@ import { configurationFormSchema } from './configuration_form_schema'; type MappingsConfiguration = Types['MappingsConfiguration']; interface Props { - defaultValue?: MappingsConfiguration; + value?: MappingsConfiguration; } -const stringifyJson = (json: { [key: string]: any }) => +const stringifyJson = (json: GenericObject) => Object.keys(json).length ? JSON.stringify(json, null, 2) : '{\n\n}'; const formSerializer: SerializerFunc = formData => { @@ -57,7 +58,7 @@ const formSerializer: SerializerFunc = formData => { }; }; -const formDeserializer = (formData: { [key: string]: any }) => { +const formDeserializer = (formData: GenericObject) => { const { dynamic, numeric_detection, @@ -86,14 +87,14 @@ const formDeserializer = (formData: { [key: string]: any }) => { }; }; -export const ConfigurationForm = React.memo(({ defaultValue }: Props) => { +export const ConfigurationForm = React.memo(({ value }: Props) => { const didMountRef = useRef(false); const { form } = useForm({ schema: configurationFormSchema, serializer: formSerializer, deserializer: formDeserializer, - defaultValue, + defaultValue: value, }); const dispatch = useDispatch(); @@ -114,14 +115,14 @@ export const ConfigurationForm = React.memo(({ defaultValue }: Props) => { useEffect(() => { if (didMountRef.current) { - // If the defaultValue has changed (it probably means that we have loaded a new JSON) + // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. form.reset({ resetValues: true }); } else { // Avoid reseting the form on component mount. didMountRef.current = true; } - }, [defaultValue]); // eslint-disable-line react-hooks/exhaustive-deps + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { return () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx index cb9b464d270c..c1a2b195a3f5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx @@ -67,6 +67,7 @@ export const DynamicMappingSection = () => ( return ( <> @@ -87,6 +88,7 @@ export const DynamicMappingSection = () => ( } else { return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx index 68b76a1203ad..7185016029e0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/meta_field_section/meta_field_section.tsx @@ -46,6 +46,7 @@ export const MetaFieldSection = () => ( 'aria-label': i18n.translate('xpack.idxMgmt.mappingsEditor.metaFieldEditorAriaLabel', { defaultMessage: '_meta field data editor', }), + 'data-test-subj': 'metaField', }, }} /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx index 7f434d6f834b..f06b292bc33c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/routing_section.tsx @@ -35,7 +35,11 @@ export const RoutingSection = () => { /> } > - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx index f79741d9a1a9..4278598dfc7c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx @@ -65,7 +65,7 @@ export const SourceFieldSection = () => { ); const renderFormFields = () => ( - <> +

{({ label, helpText, value, setValue }) => ( @@ -89,6 +89,7 @@ export const SourceFieldSection = () => { setValue([...(value as ComboBoxOption[]), newOption]); }} fullWidth + data-test-subj="includesField" /> )} @@ -119,11 +120,12 @@ export const SourceFieldSection = () => { setValue([...(value as ComboBoxOption[]), newOption]); }} fullWidth + data-test-subj="excludesField" /> )} - +
); return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx index a97e3b227311..569af5d21cdb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter.tsx @@ -25,6 +25,7 @@ interface Props { label?: string; config?: FieldConfig; allowsIndexDefaultOption?: boolean; + 'data-test-subj'?: string; } const ANALYZER_OPTIONS = PARAMETERS_OPTIONS.analyzer!; @@ -68,6 +69,7 @@ export const AnalyzerParameter = ({ label, config, allowsIndexDefaultOption = true, + 'data-test-subj': dataTestSubj, }: Props) => { const indexSettings = useIndexSettings(); const customAnalyzers = getCustomAnalyzers(indexSettings); @@ -131,6 +133,11 @@ export const AnalyzerParameter = ({ !isDefaultValueInOptions && !isDefaultValueInSubOptions ); + const [selectsDefaultValue, setSelectsDefaultValue] = useState({ + main: mainValue, + sub: subValue, + }); + const fieldConfig = config ? config : getFieldConfig('analyzer'); const fieldConfigWithLabel = label !== undefined ? { ...fieldConfig, label } : fieldConfig; @@ -142,6 +149,7 @@ export const AnalyzerParameter = ({ } field.reset({ resetValue: false }); + setSelectsDefaultValue({ main: undefined, sub: undefined }); setIsCustom(!isCustom); }; @@ -154,6 +162,7 @@ export const AnalyzerParameter = ({ size="xs" onClick={toggleCustom(field)} className="mappingsEditor__selectWithCustom__button" + data-test-subj={`${dataTestSubj}-toggleCustomButton`} > {isCustom ? i18n.translate('xpack.idxMgmt.mappingsEditor.predefinedButtonLabel', { @@ -169,17 +178,18 @@ export const AnalyzerParameter = ({ // around the field. - + ) : ( )}
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx index a91231352c16..a44fd2257f52 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx @@ -36,6 +36,7 @@ interface Props { config: FieldConfig; options: Options; mapOptionsToSubOptions: MapOptionsToSubOptions; + 'data-test-subj'?: string; } export const AnalyzerParameterSelects = ({ @@ -45,6 +46,7 @@ export const AnalyzerParameterSelects = ({ config, options, mapOptionsToSubOptions, + 'data-test-subj': dataTestSubj, }: Props) => { const { form } = useForm({ defaultValue: { main: mainDefaultValue, sub: subDefaultValue } }); @@ -76,11 +78,16 @@ export const AnalyzerParameterSelects = ({ const isSuperSelect = areOptionsSuperSelect(opts); return isSuperSelect ? ( - + ) : ( ); }; @@ -102,9 +109,9 @@ export const AnalyzerParameterSelects = ({ diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx index 0cf22946bf60..f99aa4d1eca9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzers_parameter.tsx @@ -34,6 +34,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P href: documentationService.getAnalyzerLink(), }} withToggle={false} + data-test-subj="analyzerParameters" > {({ useSameAnalyzerForSearch }) => { @@ -50,6 +51,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P path="analyzer" label={label} defaultValue={field.source.analyzer as string} + data-test-subj="indexAnalyzer" /> ); }} @@ -60,6 +62,9 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P @@ -94,6 +100,7 @@ export const AnalyzersParameter = ({ field, withSearchQuoteAnalyzer = false }: P path="search_quote_analyzer" defaultValue={field.source.search_quote_analyzer as string} config={getFieldConfig('search_quote_analyzer')} + data-test-subj="searchQuoteAnalyzer" /> )} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx index fec8e49a1991..3e91e97eef61 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index_parameter.tsx @@ -39,6 +39,7 @@ export const IndexParameter = ({ href: documentationService.getIndexLink(), }} formFieldPath="index" + data-test-subj="indexParameter" > {/* index_options */} {hasIndexOptions ? ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx index 03c774227924..2046675881c2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/advanced_parameters_section.tsx @@ -23,7 +23,7 @@ export const AdvancedParametersSection = ({ children }: Props) => {
- + {isVisible ? i18n.translate('xpack.idxMgmt.mappingsEditor.advancedSettings.hideButtonLabel', { defaultMessage: 'Hide advanced settings', @@ -33,7 +33,7 @@ export const AdvancedParametersSection = ({ children }: Props) => { })} -
+
{/* We ned to wrap the children inside a "div" to have our css :first-child rule */}
{children}
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index 489424a07e04..854270f313e5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -96,7 +96,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props
{/* Title */} -

+

{isMultiField ? i18n.translate( 'xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', @@ -127,6 +127,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props href={linkDocumentation} target="_blank" iconType="help" + data-test-subj="documentationLink" > {i18n.translate( 'xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', @@ -146,7 +147,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props {/* Field path */} - + {field.path.join(' > ')} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx index 97a7d205c135..1c079c8d5cf8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx @@ -42,6 +42,7 @@ interface Props { children?: React.ReactNode | ChildrenFunc; withToggle?: boolean; configPath?: ParameterName; + 'data-test-subj'?: string; } export const EditFieldFormRow = React.memo( @@ -54,6 +55,7 @@ export const EditFieldFormRow = React.memo( children, withToggle = true, configPath, + 'data-test-subj': dataTestSubj, }: Props) => { const form = useFormContext(); @@ -87,7 +89,7 @@ export const EditFieldFormRow = React.memo( label={title} checked={isContentVisible} onChange={onToggle} - data-test-subj="input" + data-test-subj="formRowToggle" showLabel={false} /> ) : ( @@ -99,7 +101,17 @@ export const EditFieldFormRow = React.memo( }} > {field => { - return ; + return ( + + ); }} ); @@ -165,7 +177,7 @@ export const EditFieldFormRow = React.memo( ); return ( - + {toggle} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx index 6df86d561a53..c0d922e0d1d3 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list.tsx @@ -18,7 +18,7 @@ export const FieldsList = React.memo(function FieldsListComponent({ fields, tree return null; } return ( -
    +
      {fields.map((field, index) => (
      {source.name} - + {isMultiField ? i18n.translate('xpack.idxMgmt.mappingsEditor.multiFieldBadgeLabel', { defaultMessage: '{dataType} multi-field', values: { - dataType: TYPE_DEFINITION[source.type].label, + dataType: getTypeLabelFromType(source.type), }, }) : getTypeLabelFromType(source.type)} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index 3c4d6b08ebe4..f4aa17bf6fed 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -16,7 +16,7 @@ import { documentationService } from '../../../../services/documentation'; type MappingsTemplates = Types['MappingsTemplates']; interface Props { - defaultValue?: MappingsTemplates; + value?: MappingsTemplates; } const stringifyJson = (json: { [key: string]: any }) => @@ -50,14 +50,14 @@ const formDeserializer = (formData: { [key: string]: any }) => { }; }; -export const TemplatesForm = React.memo(({ defaultValue }: Props) => { +export const TemplatesForm = React.memo(({ value }: Props) => { const didMountRef = useRef(false); const { form } = useForm({ schema: templatesFormSchema, serializer: formSerializer, deserializer: formDeserializer, - defaultValue, + defaultValue: value, }); const dispatch = useDispatch(); @@ -73,14 +73,14 @@ export const TemplatesForm = React.memo(({ defaultValue }: Props) => { useEffect(() => { if (didMountRef.current) { - // If the defaultValue has changed (it probably means that we have loaded a new JSON) + // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. form.reset({ resetValues: true }); } else { // Avoid reseting the form on component mount. didMountRef.current = true; } - }, [defaultValue]); // eslint-disable-line react-hooks/exhaustive-deps + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { return () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts index 0431ea472643..4b610ff0b401 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.test.ts @@ -6,7 +6,7 @@ jest.mock('../constants', () => ({ MAIN_DATA_TYPE_DEFINITION: {} })); -import { isStateValid } from './utils'; +import { isStateValid, stripUndefinedValues } from './utils'; describe('utils', () => { describe('isStateValid()', () => { @@ -62,4 +62,49 @@ describe('utils', () => { expect(isStateValid(components)).toBe(false); }); }); + + describe('stripUndefinedValues()', () => { + test('should remove all undefined value recursively', () => { + const myDate = new Date(); + + const dataIN = { + someString: 'world', + someNumber: 123, + someBoolean: true, + someArray: [1, 2, 3], + someEmptyObject: {}, + someDate: myDate, + falsey1: 0, + falsey2: '', + stripThis: undefined, + nested: { + value: 'bar', + stripThis: undefined, + deepNested: { + value: 'baz', + stripThis: undefined, + }, + }, + }; + + const dataOUT = { + someString: 'world', + someNumber: 123, + someBoolean: true, + someArray: [1, 2, 3], + someEmptyObject: {}, + someDate: myDate, + falsey1: 0, + falsey2: '', + nested: { + value: 'bar', + deepNested: { + value: 'baz', + }, + }, + }; + + expect(stripUndefinedValues(dataIN)).toEqual(dataOUT); + }); + }); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts index cece26618ced..306e0448df37 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/lib/utils.ts @@ -17,6 +17,7 @@ import { ChildFieldName, ParameterName, ComboBoxOption, + GenericObject, } from '../types'; import { @@ -32,11 +33,9 @@ import { State } from '../reducer'; import { FieldConfig } from '../shared_imports'; import { TreeItem } from '../components/tree'; -export const getUniqueId = () => { - return uuid.v4(); -}; +export const getUniqueId = () => uuid.v4(); -const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => { +export const getChildFieldsName = (dataType: DataType): ChildFieldName | undefined => { if (dataType === 'text' || dataType === 'keyword') { return 'fields'; } else if (dataType === 'object' || dataType === 'nested') { @@ -508,3 +507,39 @@ export const isStateValid = (state: State): boolean | undefined => return isValid && value.isValid; }, true as undefined | boolean); + +/** + * This helper removes all the keys on an object with an "undefined" value. + * To avoid sending updates from the mappings editor with this type of object: + * + *``` + * { + * "dyamic": undefined, + * "date_detection": undefined, + * "dynamic": undefined, + * "dynamic_date_formats": undefined, + * "dynamic_templates": undefined, + * "numeric_detection": undefined, + * "properties": { + * "title": { "type": "text" } + * } + * } + *``` + * + * @param obj The object to retrieve the undefined values from + * @param recursive A flag to strip recursively into children objects + */ +export const stripUndefinedValues = (obj: GenericObject, recursive = true): T => + Object.entries(obj).reduce((acc, [key, value]) => { + if (value === undefined) { + return acc; + } + + if (Array.isArray(value) || value instanceof Date || value === null) { + return { ...acc, [key]: value }; + } + + return recursive && typeof value === 'object' + ? { ...acc, [key]: stripUndefinedValues(value, recursive) } + : { ...acc, [key]: value }; + }, {} as T); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index 316fee55526a..46dc1176f62b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -21,18 +21,18 @@ import { MappingsState, Props as MappingsStateProps, Types } from './mappings_st import { IndexSettingsProvider } from './index_settings_context'; interface Props { - onUpdate: MappingsStateProps['onUpdate']; - defaultValue?: { [key: string]: any }; + onChange: MappingsStateProps['onChange']; + value?: { [key: string]: any }; indexSettings?: IndexSettings; } type TabName = 'fields' | 'advanced' | 'templates'; -export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSettings }: Props) => { +export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => { const [selectedTab, selectTab] = useState('fields'); const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => { - const mappingsDefinition = extractMappingsDefinition(defaultValue); + const mappingsDefinition = extractMappingsDefinition(value); if (mappingsDefinition === null) { return { multipleMappingsDeclared: true }; @@ -67,18 +67,18 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting }; return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; - }, [defaultValue]); + }, [value]); useEffect(() => { if (multipleMappingsDeclared) { // We set the data getter here as the user won't be able to make any changes - onUpdate({ - getData: () => defaultValue! as Types['Mappings'], + onChange({ + getData: () => value! as Types['Mappings'], validate: () => Promise.resolve(true), isValid: true, }); } - }, [multipleMappingsDeclared, onUpdate, defaultValue]); + }, [multipleMappingsDeclared, onChange, value]); const changeTab = async (tab: TabName, state: State) => { if (selectedTab === 'advanced') { @@ -108,12 +108,12 @@ export const MappingsEditor = React.memo(({ onUpdate, defaultValue, indexSetting ) : ( - + {({ state }) => { const tabToContentMap = { fields: , - templates: , - advanced: , + templates: , + advanced: , }; return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx index a9d26b953b96..280ea5c3dd28 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx @@ -16,7 +16,7 @@ import { Dispatch, } from './reducer'; import { Field } from './types'; -import { normalize, deNormalize } from './lib'; +import { normalize, deNormalize, stripUndefinedValues } from './lib'; type Mappings = MappingsTemplates & MappingsConfiguration & { @@ -43,36 +43,34 @@ const DispatchContext = createContext(undefined); export interface Props { children: (params: { state: State }) => React.ReactNode; - defaultValue: { + value: { templates: MappingsTemplates; configuration: MappingsConfiguration; fields: { [key: string]: Field }; }; - onUpdate: OnUpdateHandler; + onChange: OnUpdateHandler; } -export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: Props) => { +export const MappingsState = React.memo(({ children, onChange, value }: Props) => { const didMountRef = useRef(false); - const parsedFieldsDefaultValue = useMemo(() => normalize(defaultValue.fields), [ - defaultValue.fields, - ]); + const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); const initialState: State = { isValid: undefined, configuration: { - defaultValue: defaultValue.configuration, + defaultValue: value.configuration, data: { - raw: defaultValue.configuration, - format: () => defaultValue.configuration, + raw: value.configuration, + format: () => value.configuration, }, validate: () => Promise.resolve(true), }, templates: { - defaultValue: defaultValue.templates, + defaultValue: value.templates, data: { - raw: defaultValue.templates, - format: () => defaultValue.templates, + raw: value.templates, + format: () => value.templates, }, validate: () => Promise.resolve(true), }, @@ -105,7 +103,7 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P const bypassFieldFormValidation = state.documentFields.status === 'creatingField' && emptyNameValue; - onUpdate({ + onChange({ // Output a mappings object from the user's input. getData: (isValid: boolean) => { let nextState = state; @@ -135,8 +133,10 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P const templatesData = nextState.templates.data.format(); return { - ...configurationData, - ...templatesData, + ...stripUndefinedValues({ + ...configurationData, + ...templatesData, + }), properties: fields, }; }, @@ -169,26 +169,26 @@ export const MappingsState = React.memo(({ children, onUpdate, defaultValue }: P }, isValid: state.isValid, }); - }, [state, onUpdate]); + }, [state, onChange]); useEffect(() => { /** - * If the defaultValue has changed that probably means that we have loaded + * If the value has changed that probably means that we have loaded * new data from JSON. We need to update our state with the new mappings. */ if (didMountRef.current) { dispatch({ type: 'editor.replaceMappings', value: { - configuration: defaultValue.configuration, - templates: defaultValue.templates, + configuration: value.configuration, + templates: value.templates, fields: parsedFieldsDefaultValue, }, }); } else { didMountRef.current = true; } - }, [defaultValue, parsedFieldsDefaultValue]); + }, [value, parsedFieldsDefaultValue]); return ( diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx index cf9b57dcbcb1..d74dd435ecda 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_mappings.tsx @@ -101,8 +101,8 @@ export const StepMappings: React.FunctionComponent = ({ {/* Mappings code editor */} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index a600d59865cc..77147d1b3b2b 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -118,10 +118,7 @@ export const ExpressionChart: React.FC = ({ const series = { ...firstSeries, rows: firstSeries.rows.map(row => { - const newRow: MetricsExplorerRow = { - timestamp: row.timestamp, - metric_0: row.metric_0 || null, - }; + const newRow: MetricsExplorerRow = { ...row }; thresholds.forEach((thresholdValue, index) => { newRow[`metric_threshold_${index}`] = thresholdValue; }); @@ -224,7 +221,7 @@ export const ExpressionChart: React.FC = ({ /> ) : null} - {isAbove ? ( + {isAbove && first(expression.threshold) != null ? ( { return ( - - - - - + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx index 5ff5cd4db716..16751fabd6e9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page.tsx @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; - import { ColumnarPage } from '../../../components/page'; import { LogEntryRatePageContent } from './page_content'; import { LogEntryRatePageProviders } from './page_providers'; export const LogEntryRatePage = () => { return ( - - - - - + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/page.tsx b/x-pack/plugins/infra/public/pages/logs/page.tsx index 08049183d0a1..018f89fbb23c 100644 --- a/x-pack/plugins/infra/public/pages/logs/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page.tsx @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; - import { LogsPageContent } from './page_content'; import { LogsPageProviders } from './page_providers'; -export const LogsPage: React.FunctionComponent = ({ match }) => { +export const LogsPage: React.FunctionComponent = () => { return ( - - - + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx index 88b1441f0ba7..363b1b762710 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/source_configuration_settings.tsx @@ -7,6 +7,7 @@ import { EuiButton, EuiCallOut, + EuiErrorBoundary, EuiFlexGroup, EuiFlexItem, EuiPanel, @@ -74,7 +75,7 @@ export const LogsSettingsPage = () => { } return ( - <> + { - + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx index 712d62505214..bc25d7c49b12 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { ColumnarPage } from '../../../components/page'; @@ -15,11 +16,13 @@ export const StreamPage = () => { useTrackPageview({ app: 'infra_logs', path: 'stream' }); useTrackPageview({ app: 'infra_logs', path: 'stream', delay: 15000 }); return ( - - - - - - + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 9667272eb241..88e6ea8be432 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -64,6 +64,7 @@ export const LogsToolbar = () => { isLoadingSuggestions={isLoadingSuggestions} isValid={isFilterQueryDraftValid} loadSuggestions={loadSuggestions} + disabled={isStreaming} onChange={(expression: string) => { setSurroundingLogsId(null); setLogFilterQueryDraft(expression); diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index dbf71665ea86..91362d9098e3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -36,103 +36,105 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; return ( - - - - - - - - + + + + + + + -
      - - - - - - - - - - - + - - - ( - - {({ configuration, createDerivedIndexPattern }) => ( - - - {configuration ? ( - - ) : ( - - )} - - )} - - )} +
      - - - - - - - + + + + + + + + + + + + + + + ( + + {({ configuration, createDerivedIndexPattern }) => ( + + + {configuration ? ( + + ) : ( + + )} + + )} + + )} + /> + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 3a2c33d1c824..ebb8243369b3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiErrorBoundary, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; @@ -41,65 +41,70 @@ export const SnapshotPage = () => { }); return ( - - - i18n.translate('xpack.infra.infrastructureSnapshotPage.documentTitle', { - defaultMessage: '{previousTitle} | Inventory', - values: { - previousTitle, - }, - }) - } - /> - {isLoading ? ( - - ) : metricIndicesExist ? ( - <> - - - - ) : hasFailedLoadingSource ? ( - - ) : ( - - - - {i18n.translate('xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel', { - defaultMessage: 'View setup instructions', - })} - - - {uiCapabilities?.infrastructure?.configureSource ? ( + + + + i18n.translate('xpack.infra.infrastructureSnapshotPage.documentTitle', { + defaultMessage: '{previousTitle} | Inventory', + values: { + previousTitle, + }, + }) + } + /> + {isLoading ? ( + + ) : metricIndicesExist ? ( + <> + + + + ) : hasFailedLoadingSource ? ( + + ) : ( + - - {i18n.translate('xpack.infra.configureSourceActionLabel', { - defaultMessage: 'Change source configuration', - })} - + {i18n.translate( + 'xpack.infra.homePage.noMetricsIndicesInstructionsActionLabel', + { + defaultMessage: 'View setup instructions', + } + )} + - ) : null} - - } - data-test-subj="noMetricsIndicesPrompt" - /> - )} - + {uiCapabilities?.infrastructure?.configureSource ? ( + + + {i18n.translate('xpack.infra.configureSourceActionLabel', { + defaultMessage: 'Change source configuration', + })} + + + ) : null} + + } + data-test-subj="noMetricsIndicesPrompt" + /> + )} + + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx index 597977d9d273..dcd1c1d94997 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/page_providers.tsx @@ -4,17 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; - import { Source } from '../../../containers/source'; import { MetricsTimeProvider } from './hooks/use_metrics_time'; export const withMetricPageProviders = (Component: React.ComponentType) => ( props: T ) => ( - - - - - + + + + + + + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts index 559422584f57..f773c843d12f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_tsvb_link.ts @@ -46,6 +46,24 @@ export const metricsExplorerMetricToTSVBMetric = (metric: MetricsExplorerOptions field: derivativeId, }, ]; + } else if (metric.aggregation === 'p95' || metric.aggregation === 'p99') { + const percentileValue = metric.aggregation === 'p95' ? '95' : '99'; + return [ + { + id: uuid.v1(), + type: 'percentile', + field: metric.field, + percentiles: [ + { + id: uuid.v1(), + value: percentileValue, + mode: 'line', + percentile: '', + shade: 0.2, + }, + ], + }, + ]; } else { return [ { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx index 3b84fcbc3483..223318da8cf4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx @@ -20,6 +20,7 @@ import { MetricsExplorerOptionsMetric, MetricsExplorerChartType, } from '../hooks/use_metrics_explorer_options'; +import { getMetricId } from './helpers/get_metric_id'; type NumberOrString = string | number; @@ -45,10 +46,12 @@ export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opac colorTransformer(MetricsExplorerColor.color0); const yAccessors = Array.isArray(id) - ? id.map(i => `metric_${i}`).slice(id.length - 1, id.length) - : [`metric_${id}`]; + ? id.map(i => getMetricId(metric, i)).slice(id.length - 1, id.length) + : [getMetricId(metric, id)]; const y0Accessors = - Array.isArray(id) && id.length > 1 ? id.map(i => `metric_${i}`).slice(0, 1) : undefined; + Array.isArray(id) && id.length > 1 + ? id.map(i => getMetricId(metric, i)).slice(0, 1) + : undefined; const chartId = `series-${series.id}-${yAccessors.join('-')}`; const seriesAreaStyle: RecursivePartial = { @@ -85,8 +88,10 @@ export const MetricsExplorerBarChart = ({ metric, id, series, stack }: Props) => (metric.color && colorTransformer(metric.color)) || colorTransformer(MetricsExplorerColor.color0); - const yAccessor = `metric_${id}`; - const chartId = `series-${series.id}-${yAccessor}`; + const yAccessors = Array.isArray(id) + ? id.map(i => getMetricId(metric, i)).slice(id.length - 1, id.length) + : [getMetricId(metric, id)]; + const chartId = `series-${series.id}-${yAccessors.join('-')}`; const seriesBarStyle: RecursivePartial = { rectBorder: { @@ -100,13 +105,13 @@ export const MetricsExplorerBarChart = ({ metric, id, series, stack }: Props) => }; return ( + i18n.translate('xpack.infra.infrastructureMetricsExplorerPage.documentTitle', { @@ -95,6 +95,6 @@ export const MetricsExplorerPage = ({ source, derivedIndexPattern }: MetricsExpl onTimeChange={handleTimeChange} /> )} - + ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/settings.tsx b/x-pack/plugins/infra/public/pages/metrics/settings.tsx index 9414eb7d3e56..7d4f35b19da7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/settings.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/settings.tsx @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiErrorBoundary } from '@elastic/eui'; import React from 'react'; -import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { SourceConfigurationSettings } from '../../components/source_configuration/source_configuration_settings'; export const MetricsSettingsPage = () => { const uiCapabilities = useKibana().services.application?.capabilities; return ( - + + + ); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.ts new file mode 100644 index 000000000000..2c83f6ecfd70 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/create_percentile_aggregation.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Aggregators } from './types'; +export const createPercentileAggregation = ( + type: Aggregators.P95 | Aggregators.P99, + field: string +) => { + const value = type === Aggregators.P95 ? 95 : 99; + return { + aggregatedValue: { + percentiles: { + field, + percents: [value], + keyed: false, + }, + }, + }; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 2531e939792a..ed5efc147395 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -233,6 +233,58 @@ describe('The metric threshold alert type', () => { expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); + describe('querying with the p99 aggregator', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + aggType: 'p99', + metric: 'test.metric.2', + }, + ], + }, + }); + test('alerts based on the p99 values', async () => { + await execute(Comparator.GT, [1]); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [1]); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + }); + }); + describe('querying with the p95 aggregator', () => { + const instanceID = 'test-*'; + const execute = (comparator: Comparator, threshold: number[]) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator, + threshold, + aggType: 'p95', + metric: 'test.metric.1', + }, + ], + }, + }); + test('alerts based on the p95 values', async () => { + await execute(Comparator.GT, [0.25]); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); + await execute(Comparator.LT, [0.95]); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.OK); + }); + }); describe("querying a metric that hasn't reported data", () => { const instanceID = 'test-*'; const execute = (alertOnNoData: boolean) => diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index ec9389537835..71bee3209bf5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { mapValues } from 'lodash'; +import { mapValues, first } from 'lodash'; import { i18n } from '@kbn/i18n'; import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; @@ -21,12 +21,16 @@ import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/ser import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { getDateHistogramOffset } from '../../snapshot/query_helpers'; import { InfraBackendLibs } from '../../infra_types'; +import { createPercentileAggregation } from './create_percentile_aggregation'; const TOTAL_BUCKETS = 5; interface Aggregation { aggregatedIntervals: { - buckets: Array<{ aggregatedValue: { value: number }; doc_count: number }>; + buckets: Array<{ + aggregatedValue: { value: number; values?: Array<{ key: number; value: number }> }; + doc_count: number; + }>; }; } @@ -47,6 +51,12 @@ const getCurrentValueFromAggregations = ( if (aggType === Aggregators.COUNT) { return mostRecentBucket.doc_count; } + if (aggType === Aggregators.P95 || aggType === Aggregators.P99) { + const values = mostRecentBucket.aggregatedValue?.values || []; + const firstValue = first(values); + if (!firstValue) return null; + return firstValue.value; + } const { value } = mostRecentBucket.aggregatedValue; return value; } catch (e) { @@ -86,6 +96,8 @@ export const getElasticsearchMetricQuery = ( ? {} : aggType === Aggregators.RATE ? networkTraffic('aggregatedValue', metric) + : aggType === Aggregators.P95 || aggType === Aggregators.P99 + ? createPercentileAggregation(aggType, metric) : { aggregatedValue: { [aggType]: { @@ -275,7 +287,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s ); // Because each alert result has the same group definitions, just grap the groups from the first one. - const groups = Object.keys(alertResults[0]); + const groups = Object.keys(first(alertResults)); for (const group of groups) { const alertInstance = services.alertInstanceFactory(`${alertId}-${group}`); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index fa55f80e472d..25b709d6afc5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -7,22 +7,22 @@ const bucketsA = [ { doc_count: 2, - aggregatedValue: { value: 0.5 }, + aggregatedValue: { value: 0.5, values: [{ key: 95.0, value: 0.5 }] }, }, { doc_count: 3, - aggregatedValue: { value: 1.0 }, + aggregatedValue: { value: 1.0, values: [{ key: 95.0, value: 1.0 }] }, }, ]; const bucketsB = [ { doc_count: 4, - aggregatedValue: { value: 2.5 }, + aggregatedValue: { value: 2.5, values: [{ key: 99.0, value: 2.5 }] }, }, { doc_count: 5, - aggregatedValue: { value: 3.5 }, + aggregatedValue: { value: 3.5, values: [{ key: 99.0, value: 3.5 }] }, }, ]; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 18f5503fe2c9..76ddd107bd72 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -23,6 +23,8 @@ export enum Aggregators { MAX = 'max', RATE = 'rate', CARDINALITY = 'cardinality', + P95 = 'p95', + P99 = 'p99', } export enum AlertStates { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts index f6db5dfe353e..6cdcb8782f38 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -372,12 +372,11 @@ export const ensureDefaultIndices = async (callCluster: CallESAsCurrentUser) => Promise.all( Object.keys(IndexPatternType).map(async indexPattern => { const defaultIndexPatternName = indexPattern + INDEX_PATTERN_PLACEHOLDER_SUFFIX; - const indexExists = await doesIndexExist(defaultIndexPatternName, callCluster); + const indexExists = await callCluster('indices.exists', { index: defaultIndexPatternName }); if (!indexExists) { try { - await callCluster('transport.request', { - method: 'PUT', - path: `/${defaultIndexPatternName}`, + await callCluster('indices.create', { + index: defaultIndexPatternName, body: { mappings: { properties: { @@ -387,20 +386,9 @@ export const ensureDefaultIndices = async (callCluster: CallESAsCurrentUser) => }, }); } catch (putErr) { - throw new Error(`${defaultIndexPatternName} could not be created`); + // throw new Error(`${defaultIndexPatternName} could not be created`); + throw new Error(putErr); } } }) ); - -export const doesIndexExist = async (indexName: string, callCluster: CallESAsCurrentUser) => { - try { - await callCluster('transport.request', { - method: 'HEAD', - path: indexName, - }); - return true; - } catch (err) { - return false; - } -}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts new file mode 100644 index 000000000000..3a2ee7ef8b00 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon, { SinonFakeServer } from 'sinon'; + +import { API_BASE_PATH } from '../../../common/constants'; + +// Register helpers to mock HTTP Requests +const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { + const setLoadPipelinesResponse = (response?: any[], error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', API_BASE_PATH, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setLoadPipelineResponse = (response?: {}, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', `${API_BASE_PATH}/:name`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + + const setDeletePipelineResponse = (response?: object) => { + server.respondWith('DELETE', `${API_BASE_PATH}/:name`, [ + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + + const setCreatePipelineResponse = (response?: object, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('POST', API_BASE_PATH, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + + return { + setLoadPipelinesResponse, + setLoadPipelineResponse, + setDeletePipelineResponse, + setCreatePipelineResponse, + }; +}; + +export const init = () => { + const server = sinon.fakeServer.create(); + server.respondImmediately = true; + + // Define default response for unhandled requests. + // We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry, + // and we can mock them all with a 200 instead of mocking each one individually. + server.respondWith([200, {}, 'DefaultMockedResponse']); + + const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server); + + return { + server, + httpRequestsMockHelpers, + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts new file mode 100644 index 000000000000..6216119c5d1d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setup as pipelinesListSetup } from './pipelines_list.helpers'; +import { setup as pipelinesCreateSetup } from './pipelines_create.helpers'; +import { setup as pipelinesCloneSetup } from './pipelines_clone.helpers'; +import { setup as pipelinesEditSetup } from './pipelines_edit.helpers'; + +export { nextTick, getRandomString, findTestSubject } from '../../../../../test_utils'; + +export { setupEnvironment } from './setup_environment'; + +export const pageHelpers = { + pipelinesList: { setup: pipelinesListSetup }, + pipelinesCreate: { setup: pipelinesCreateSetup }, + pipelinesClone: { setup: pipelinesCloneSetup }, + pipelinesEdit: { setup: pipelinesEditSetup }, +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts new file mode 100644 index 000000000000..d56e92a2419c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipeline_form.helpers.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TestBed } from '../../../../../test_utils'; + +export const getFormActions = (testBed: TestBed) => { + const { find, form } = testBed; + + // User actions + const clickSubmitButton = () => { + find('submitButton').simulate('click'); + }; + + const clickTestPipelineButton = () => { + find('testPipelineButton').simulate('click'); + }; + + const clickShowRequestLink = () => { + find('showRequestLink').simulate('click'); + }; + + const toggleVersionSwitch = () => { + form.toggleEuiSwitch('versionToggle'); + }; + + const toggleOnFailureSwitch = () => { + form.toggleEuiSwitch('onFailureToggle'); + }; + + return { + clickSubmitButton, + clickShowRequestLink, + toggleVersionSwitch, + toggleOnFailureSwitch, + clickTestPipelineButton, + }; +}; + +export type PipelineFormTestSubjects = + | 'submitButton' + | 'pageTitle' + | 'savePipelineError' + | 'pipelineForm' + | 'versionToggle' + | 'versionField' + | 'nameField.input' + | 'descriptionField.input' + | 'processorsField' + | 'onFailureToggle' + | 'onFailureEditor' + | 'testPipelineButton' + | 'showRequestLink' + | 'requestFlyout' + | 'requestFlyout.title' + | 'testPipelineFlyout' + | 'testPipelineFlyout.title' + | 'documentationLink'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts new file mode 100644 index 000000000000..2791ffc32c85 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_clone.helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesClone } from '../../../public/application/sections/pipelines_clone'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesCloneTestBed = TestBed & { + actions: ReturnType; +}; + +export const PIPELINE_TO_CLONE = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [ + { + set: { + field: 'foo', + value: 'new', + }, + }, + ], +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}create/${PIPELINE_TO_CLONE.name}`], + componentRoutePath: `${BASE_PATH}create/:name`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesClone), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.ts new file mode 100644 index 000000000000..54a62a8357e5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_create.helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesCreate } from '../../../public/application/sections/pipelines_create'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesCreateTestBed = TestBed & { + actions: ReturnType; +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}/create`], + componentRoutePath: `${BASE_PATH}/create`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesCreate), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts new file mode 100644 index 000000000000..12320f034a81 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_edit.helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerTestBed, TestBedConfig, TestBed } from '../../../../../test_utils'; +import { BASE_PATH } from '../../../common/constants'; +import { PipelinesEdit } from '../../../public/application/sections/pipelines_edit'; // eslint-disable-line @kbn/eslint/no-restricted-paths +import { getFormActions, PipelineFormTestSubjects } from './pipeline_form.helpers'; +import { WithAppDependencies } from './setup_environment'; + +export type PipelinesEditTestBed = TestBed & { + actions: ReturnType; +}; + +export const PIPELINE_TO_EDIT = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [ + { + set: { + field: 'foo', + value: 'new', + }, + }, + ], +}; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [`${BASE_PATH}edit/${PIPELINE_TO_EDIT.name}`], + componentRoutePath: `${BASE_PATH}edit/:name`, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesEdit), testBedConfig); + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: getFormActions(testBed), + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts new file mode 100644 index 000000000000..0f9745981c18 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/pipelines_list.helpers.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { BASE_PATH } from '../../../common/constants'; +import { + registerTestBed, + TestBed, + TestBedConfig, + findTestSubject, + nextTick, +} from '../../../../../test_utils'; +import { PipelinesList } from '../../../public/application/sections/pipelines_list'; +import { WithAppDependencies } from './setup_environment'; + +const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [BASE_PATH], + componentRoutePath: BASE_PATH, + }, + doMountAsync: true, +}; + +const initTestBed = registerTestBed(WithAppDependencies(PipelinesList), testBedConfig); + +export type PipelineListTestBed = TestBed & { + actions: ReturnType; +}; + +const createActions = (testBed: TestBed) => { + const { find } = testBed; + + /** + * User Actions + */ + const clickReloadButton = () => { + find('reloadButton').simulate('click'); + }; + + const clickPipelineAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('pipelinesTable'); + const pipelineLink = findTestSubject(rows[index].reactWrapper, 'pipelineDetailsLink'); + + await act(async () => { + const { href } = pipelineLink.props(); + router.navigateTo(href!); + await nextTick(); + component.update(); + }); + }; + + const clickActionMenu = (pipelineName: string) => { + const { component } = testBed; + + // When a table has > 2 actions, EUI displays an overflow menu with an id "-actions" + component.find(`div[id="${pipelineName}-actions"] button`).simulate('click'); + }; + + const clickPipelineAction = (pipelineName: string, action: 'edit' | 'clone' | 'delete') => { + const actions = ['edit', 'clone', 'delete']; + const { component } = testBed; + + clickActionMenu(pipelineName); + + component + .find('.euiContextMenuItem') + .at(actions.indexOf(action)) + .simulate('click'); + }; + + return { + clickReloadButton, + clickPipelineAt, + clickPipelineAction, + clickActionMenu, + }; +}; + +export const setup = async (): Promise => { + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type PipelineListTestSubjects = + | 'appTitle' + | 'documentationLink' + | 'createPipelineButton' + | 'pipelinesTable' + | 'pipelineDetails' + | 'pipelineDetails.title' + | 'deletePipelinesConfirmation' + | 'emptyList' + | 'emptyList.title' + | 'sectionLoading' + | 'pipelineLoadError' + | 'reloadButton'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx new file mode 100644 index 000000000000..3243d665832f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import React from 'react'; + +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { + notificationServiceMock, + fatalErrorsServiceMock, + docLinksServiceMock, + injectedMetadataServiceMock, +} from '../../../../../../src/core/public/mocks'; + +import { usageCollectionPluginMock } from '../../../../../../src/plugins/usage_collection/public/mocks'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { HttpService } from '../../../../../../src/core/public/http'; + +import { + breadcrumbService, + documentationService, + uiMetricService, + apiService, +} from '../../../public/application/services'; + +import { init as initHttpRequests } from './http_requests'; + +const httpServiceSetupMock = new HttpService().setup({ + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + fatalErrors: fatalErrorsServiceMock.createSetupContract(), +}); + +const appServices = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications: notificationServiceMock.createSetupContract(), +}; + +export const setupEnvironment = () => { + uiMetricService.setup(usageCollectionPluginMock.createSetupContract()); + apiService.setup(httpServiceSetupMock, uiMetricService); + documentationService.setup(docLinksServiceMock.createStartContract()); + breadcrumbService.setup(() => {}); + + const { server, httpRequestsMockHelpers } = initHttpRequests(); + + return { + server, + httpRequestsMockHelpers, + }; +}; + +export const WithAppDependencies = (Comp: any) => (props: any) => ( + + + +); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx new file mode 100644 index 000000000000..290136789221 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_clone.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import { PIPELINE_TO_CLONE, PipelinesCloneTestBed } from './helpers/pipelines_clone.helpers'; + +const { setup } = pageHelpers.pipelinesClone; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesCloneTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_CLONE); + + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create pipeline'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Create pipeline docs'); + }); + + describe('form submission', () => { + it('should send the correct payload', async () => { + const { actions, waitFor } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + ...PIPELINE_TO_CLONE, + name: `${PIPELINE_TO_CLONE.name}-copy`, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx new file mode 100644 index 000000000000..e0be8d293772 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_create.test.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PipelinesCreateTestBed } from './helpers/pipelines_create.helpers'; + +const { setup } = pageHelpers.pipelinesCreate; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesCreateTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('on component mount', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create pipeline'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Create pipeline docs'); + }); + + test('should toggle the version field', async () => { + const { actions, component, exists } = testBed; + + // Version field should be hidden by default + expect(exists('versionField')).toBe(false); + + await act(async () => { + actions.toggleVersionSwitch(); + await nextTick(); + component.update(); + }); + + expect(exists('versionField')).toBe(true); + }); + + test('should toggle the on-failure processors editor', async () => { + const { actions, component, exists } = testBed; + + // On-failure editor should be hidden by default + expect(exists('onFailureEditor')).toBe(false); + + await act(async () => { + actions.toggleOnFailureSwitch(); + await nextTick(); + component.update(); + }); + + expect(exists('onFailureEditor')).toBe(true); + }); + + test('should show the request flyout', async () => { + const { actions, component, find, exists } = testBed; + + await act(async () => { + actions.clickShowRequestLink(); + await nextTick(); + component.update(); + }); + + // Verify request flyout opens + expect(exists('requestFlyout')).toBe(true); + expect(find('requestFlyout.title').text()).toBe('Request'); + }); + + describe('form validation', () => { + test('should prevent form submission if required fields are missing', async () => { + const { form, actions, component, find } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await nextTick(); + component.update(); + }); + + expect(form.getErrorsMessages()).toEqual([ + 'Name is required.', + 'A description is required.', + ]); + expect(find('submitButton').props().disabled).toEqual(true); + + // Add required fields and verify button is enabled again + form.setInputValue('nameField.input', 'my_pipeline'); + form.setInputValue('descriptionField.input', 'pipeline description'); + + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(find('submitButton').props().disabled).toEqual(false); + }); + }); + + describe('form submission', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + + const { waitFor, form } = testBed; + + await waitFor('pipelineForm'); + + form.setInputValue('nameField.input', 'my_pipeline'); + form.setInputValue('descriptionField.input', 'pipeline description'); + }); + }); + + test('should send the correct payload', async () => { + const { actions, waitFor } = testBed; + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const expected = { + name: 'my_pipeline', + description: 'pipeline description', + processors: [], + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + + test('should surface API errors from the request', async () => { + const { actions, find, exists, waitFor } = testBed; + + const error = { + status: 409, + error: 'Conflict', + message: `There is already a pipeline with name 'my_pipeline'.`, + }; + + httpRequestsMockHelpers.setCreatePipelineResponse(undefined, { body: error }); + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('savePipelineError'); + }); + + expect(exists('savePipelineError')).toBe(true); + expect(find('savePipelineError').text()).toContain(error.message); + }); + }); + + describe('test pipeline', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(); + + const { waitFor } = testBed; + + await waitFor('pipelineForm'); + }); + }); + + test('should open the test pipeline flyout', async () => { + const { actions, exists, find, waitFor } = testBed; + + await act(async () => { + actions.clickTestPipelineButton(); + await waitFor('testPipelineFlyout'); + }); + + // Verify test pipeline flyout opens + expect(exists('testPipelineFlyout')).toBe(true); + expect(find('testPipelineFlyout.title').text()).toBe('Test pipeline'); + }); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx new file mode 100644 index 000000000000..477eec83f876 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_edit.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import { PIPELINE_TO_EDIT, PipelinesEditTestBed } from './helpers/pipelines_edit.helpers'; + +const { setup } = pageHelpers.pipelinesEdit; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + // Mocking EuiCodeEditor, which uses React Ace under the hood + EuiCodeEditor: (props: any) => ( + { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), +})); + +describe('', () => { + let testBed: PipelinesEditTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelineResponse(PIPELINE_TO_EDIT); + + await act(async () => { + testBed = await setup(); + await testBed.waitFor('pipelineForm'); + }); + }); + + test('should render the correct page header', () => { + const { exists, find } = testBed; + + // Verify page title + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual(`Edit pipeline '${PIPELINE_TO_EDIT.name}'`); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Edit pipeline docs'); + }); + + it('should disable the name field', () => { + const { find } = testBed; + + const nameInput = find('nameField.input'); + expect(nameInput.props().disabled).toEqual(true); + }); + + describe('form submission', () => { + it('should send the correct payload with changed values', async () => { + const UPDATED_DESCRIPTION = 'updated pipeline description'; + const { actions, form, waitFor } = testBed; + + // Make change to description field + form.setInputValue('descriptionField.input', UPDATED_DESCRIPTION); + + await act(async () => { + actions.clickSubmitButton(); + await waitFor('pipelineForm', 0); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + const { name, ...pipelineDefinition } = PIPELINE_TO_EDIT; + + const expected = { + ...pipelineDefinition, + description: UPDATED_DESCRIPTION, + }; + + expect(JSON.parse(latestRequest.requestBody)).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts new file mode 100644 index 000000000000..3e0b78d4f2e9 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/ingest_pipelines_list.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act } from 'react-dom/test-utils'; + +import { API_BASE_PATH } from '../../common/constants'; + +import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { PipelineListTestBed } from './helpers/pipelines_list.helpers'; + +const { setup } = pageHelpers.pipelinesList; + +jest.mock('ui/i18n', () => { + const I18nContext = ({ children }: any) => children; + return { I18nContext }; +}); + +describe('', () => { + const { server, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: PipelineListTestBed; + + afterAll(() => { + server.restore(); + }); + + describe('With pipelines', () => { + const pipeline1 = { + name: 'test_pipeline1', + description: 'test_pipeline1 description', + processors: [], + }; + + const pipeline2 = { + name: 'test_pipeline2', + description: 'test_pipeline2 description', + processors: [], + }; + + const pipelines = [pipeline1, pipeline2]; + + httpRequestsMockHelpers.setLoadPipelinesResponse(pipelines); + + beforeEach(async () => { + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('pipelinesTable'); + }); + }); + + test('should render the list view', async () => { + const { exists, find, table } = testBed; + + // Verify app title + expect(exists('appTitle')).toBe(true); + expect(find('appTitle').text()).toEqual('Ingest Node Pipelines'); + + // Verify documentation link + expect(exists('documentationLink')).toBe(true); + expect(find('documentationLink').text()).toBe('Ingest Node Pipelines docs'); + + // Verify create button exists + expect(exists('createPipelineButton')).toBe(true); + + // Verify table content + const { tableCellsValues } = table.getMetaData('pipelinesTable'); + tableCellsValues.forEach((row, i) => { + const pipeline = pipelines[i]; + + expect(row).toEqual(['', pipeline.name, '']); + }); + }); + + test('should reload the pipeline data', async () => { + const { component, actions } = testBed; + const totalRequests = server.requests.length; + + await act(async () => { + actions.clickReloadButton(); + await nextTick(100); + component.update(); + }); + + expect(server.requests.length).toBe(totalRequests + 1); + expect(server.requests[server.requests.length - 1].url).toBe(API_BASE_PATH); + }); + + test('should show the details of a pipeline', async () => { + const { find, exists, actions } = testBed; + + await actions.clickPipelineAt(0); + + expect(exists('pipelinesTable')).toBe(true); + expect(exists('pipelineDetails')).toBe(true); + expect(find('pipelineDetails.title').text()).toBe(pipeline1.name); + }); + + test('should delete a pipeline', async () => { + const { actions, component } = testBed; + const { name: pipelineName } = pipeline1; + + httpRequestsMockHelpers.setDeletePipelineResponse({ + itemsDeleted: [pipelineName], + errors: [], + }); + + actions.clickPipelineAction(pipelineName, 'delete'); + + // We need to read the document "body" as the modal is added there and not inside + // the component DOM tree. + const modal = document.body.querySelector('[data-test-subj="deletePipelinesConfirmation"]'); + const confirmButton: HTMLButtonElement | null = modal!.querySelector( + '[data-test-subj="confirmModalConfirmButton"]' + ); + + expect(modal).not.toBe(null); + expect(modal!.textContent).toContain('Delete pipeline'); + + await act(async () => { + confirmButton!.click(); + await nextTick(); + component.update(); + }); + + const latestRequest = server.requests[server.requests.length - 1]; + + expect(latestRequest.method).toBe('DELETE'); + expect(latestRequest.url).toBe(`${API_BASE_PATH}/${pipelineName}`); + expect(latestRequest.status).toEqual(200); + }); + }); + + describe('No pipelines', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPipelinesResponse([]); + + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('emptyList'); + }); + }); + + test('should display an empty prompt', async () => { + const { exists, find } = testBed; + + expect(exists('sectionLoading')).toBe(false); + expect(exists('emptyList')).toBe(true); + expect(find('emptyList.title').text()).toEqual('Start by creating a pipeline'); + }); + }); + + describe('Error handling', () => { + beforeEach(async () => { + const error = { + status: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadPipelinesResponse(undefined, { body: error }); + + testBed = await setup(); + + await act(async () => { + const { waitFor } = testBed; + + await waitFor('pipelineLoadError'); + }); + }); + + test('should render an error message if error fetching pipelines', async () => { + const { exists, find } = testBed; + + expect(exists('pipelineLoadError')).toBe(true); + expect(find('pipelineLoadError').text()).toContain('Unable to load pipelines.'); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index 9082196a48b3..55523bfa7d11 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -126,6 +126,7 @@ export const PipelineForm: React.FunctionComponent = ({ setIsRequestVisible(prevIsRequestVisible => !prevIsRequestVisible)} > {isRequestVisible ? ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index b90683426887..8144228b1e9d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -140,7 +140,12 @@ export const PipelineFormFields: React.FunctionComponent = ({ - + = ({ path="processors" component={JsonEditorField} componentProps={{ - ['data-test-subj']: 'processorsField', euiCodeEditorProps: { + ['data-test-subj']: 'processorsField', height: '300px', 'aria-label': i18n.translate('xpack.ingestPipelines.form.processorsFieldAriaLabel', { defaultMessage: 'Processors JSON editor', @@ -211,8 +216,8 @@ export const PipelineFormFields: React.FunctionComponent = ({ path="on_failure" component={JsonEditorField} componentProps={{ - ['data-test-subj']: 'onFailureEditor', euiCodeEditorProps: { + ['data-test-subj']: 'onFailureEditor', height: '300px', 'aria-label': i18n.translate('xpack.ingestPipelines.form.onFailureFieldAriaLabel', { defaultMessage: 'Failure processors JSON editor', diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx index 7cfe887d68d5..2ab7e84b3bb2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx @@ -40,10 +40,10 @@ export const PipelineRequestFlyout: React.FunctionComponent = ({ uuid.current++; return ( - + -

      +

      {name ? ( + -

      +

      {pipeline.name ? ( - -

      + +

      - -

      + +

      = ({ - +

      {pipeline.name}

      diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx index 318a9219b201..f6fe2f0cf65f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx @@ -18,8 +18,9 @@ export const EmptyList: FunctionComponent = () => { +

      {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptTitle', { defaultMessage: 'Start by creating a pipeline', })} diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 23d105c807c8..948290b16913 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -80,11 +80,15 @@ export const PipelinesList: React.FunctionComponent = ({ history.push(BASE_PATH); }; + if (data && data.length === 0) { + return ; + } + let content: React.ReactNode; if (isLoading) { content = ( - + = ({ pipelines={data} /> ); - } else { - return ; } const renderFlyout = (): React.ReactNode => { @@ -148,6 +150,7 @@ export const PipelinesList: React.FunctionComponent = ({ href={services.documentation.getIngestNodeUrl()} target="_blank" iconType="help" + data-test-subj="documentationLink" > = ({ = ({ const tableProps: EuiInMemoryTableProps = { itemId: 'name', isSelectable: true, + 'data-test-subj': 'pipelinesTable', sorting: { sort: { field: 'name', direction: 'asc' } }, selection: { onSelectionChange: setSelection, @@ -91,7 +92,11 @@ export const PipelineTable: FunctionComponent = ({ defaultMessage: 'Name', }), sortable: true, - render: (name: string) => {name}, + render: (name: string) => ( + + {name} + + ), }, { name: ( diff --git a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx index be6830c11583..08f55850b119 100644 --- a/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx +++ b/x-pack/plugins/lens/public/debounced_component/debounced_component.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useMemo, memo, FunctionComponent } from 'react'; +import React, { useState, useMemo, useEffect, memo, FunctionComponent } from 'react'; import { debounce } from 'lodash'; /** @@ -17,7 +17,11 @@ export function debouncedComponent(component: FunctionComponent, return (props: TProps) => { const [cachedProps, setCachedProps] = useState(props); - const delayRender = useMemo(() => debounce(setCachedProps, delay), []); + const debouncePropsChange = debounce(setCachedProps, delay); + const delayRender = useMemo(() => debouncePropsChange, []); + + // cancel debounced prop change if component has been unmounted in the meantime + useEffect(() => () => debouncePropsChange.cancel(), []); delayRender(props); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index f7be82dd34ba..81476e8fa370 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -43,6 +43,12 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); + const [popoverState, setPopoverState] = useState({ + isOpen: false, + openId: null, + addingToGroupId: null, + }); + const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; if (!datasourcePublicAPI) { @@ -74,12 +80,6 @@ export function LayerPanel( dateRange: props.framePublicAPI.dateRange, }; - const [popoverState, setPopoverState] = useState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some(d => d.accessors.length > 0); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 5cd803e7cebb..6da9a9471108 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -61,6 +61,8 @@ export function EditorFrame(props: EditorFrameProps) { // Initialize current datasource and all active datasources useEffect(() => { + // prevents executing dispatch on unmounted component + let isUnmounted = false; if (!allLoaded) { Object.entries(props.datasourceMap).forEach(([datasourceId, datasource]) => { if ( @@ -70,16 +72,21 @@ export function EditorFrame(props: EditorFrameProps) { datasource .initialize(state.datasourceStates[datasourceId].state || undefined) .then(datasourceState => { - dispatch({ - type: 'UPDATE_DATASOURCE_STATE', - updater: datasourceState, - datasourceId, - }); + if (!isUnmounted) { + dispatch({ + type: 'UPDATE_DATASOURCE_STATE', + updater: datasourceState, + datasourceId, + }); + } }) .catch(onError); } }); } + return () => { + isUnmounted = true; + }; }, [allLoaded]); const datasourceLayers: Record = {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx index 1f741ca37934..e246d8e27a70 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx @@ -122,6 +122,16 @@ export function InnerWorkspacePanel({ framePublicAPI.filters, ]); + useEffect(() => { + // reset expression error if component attempts to run it again + if (expression && localState.expressionBuildError) { + setLocalState(s => ({ + ...s, + expressionBuildError: undefined, + })); + } + }, [expression]); + function onDrop() { if (suggestionForDraggedField) { trackUiEvent('drop_onto_workspace'); @@ -174,16 +184,6 @@ export function InnerWorkspacePanel({ } function renderVisualization() { - useEffect(() => { - // reset expression error if component attempts to run it again - if (expression && localState.expressionBuildError) { - setLocalState(s => ({ - ...s, - expressionBuildError: undefined, - })); - } - }, [expression]); - if (expression === null) { return renderEmptyWorkspace(); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index c396f0efee42..5e3b32f6961e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -258,7 +258,17 @@ describe('IndexPattern Data Panel', () => { it('should render a warning if there are no index patterns', () => { const wrapper = shallowWithIntl( - + {} }} + changeIndexPattern={jest.fn()} + /> ); expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 79dcdafd916b..b013f2b9d22a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -144,21 +144,49 @@ export function IndexPatternDataPanel({ indexPatternList.map(x => `${x.title}:${x.timeFieldName}`).join(','), ]} /> - + + {Object.keys(indexPatterns).length === 0 ? ( + + + +

      + +

      +
      +
      +
      + ) : ( + + )} ); } @@ -194,35 +222,6 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ onChangeIndexPattern: (newId: string) => void; existingFields: IndexPatternPrivateState['existingFields']; }) { - if (Object.keys(indexPatterns).length === 0) { - return ( - - - -

      - -

      -
      -
      -
      - ); - } - const [localState, setLocalState] = useState({ nameFilter: '', typeFilter: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx index 04e13fead6fc..7e2af6a19b04 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -127,7 +127,7 @@ export function BucketNestingEditor({ defaultMessage: 'Entire data set', }), }, - ...aggColumns, + ...aggColumns.map(({ value, text }) => ({ value, text })), ]} value={prevColumn} onChange={e => setColumns(nestColumn(layer.columnOrder, e.target.value, columnId))} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index c4d2a6f8780c..5f0fa95ad002 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -251,22 +251,6 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { const IS_DARK_THEME = core.uiSettings.get('theme:darkMode'); const chartTheme = IS_DARK_THEME ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme; - - if (props.isLoading) { - return ; - } else if ( - (!props.histogram || props.histogram.buckets.length === 0) && - (!props.topValues || props.topValues.buckets.length === 0) - ) { - return ( - - {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { - defaultMessage: 'No data to display.', - })} - - ); - } - let histogramDefault = !!props.histogram; const totalValuesCount = @@ -309,6 +293,21 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { let title = <>; + if (props.isLoading) { + return ; + } else if ( + (!props.histogram || props.histogram.buckets.length === 0) && + (!props.topValues || props.topValues.buckets.length === 0) + ) { + return ( + + {i18n.translate('xpack.lens.indexPattern.fieldStatsNoData', { + defaultMessage: 'No data to display.', + })} + + ); + } + if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) { title = ( { + id?: string; + type?: string; + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + layers: Record< + string, + { + columnOrder: string[]; + columns: Record; + } + >; + }; + }; + visualization: VisualizationState; + query: unknown; + filters: unknown[]; + }; +} interface XYLayerPre77 { layerId: string; @@ -15,13 +43,23 @@ interface XYLayerPre77 { accessors: string[]; } +interface XYStatePre77 { + layers: XYLayerPre77[]; +} + +interface XYStatePost77 { + layers: Array>; +} + /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => { - const expression: string = doc.attributes?.expression; +const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => { + const expression = doc.attributes.expression; + if (!expression) { + return doc; + } try { const ast = fromExpression(expression); const newChain: ExpressionFunctionAST[] = ast.chain.map(topNode => { @@ -74,9 +112,11 @@ const removeLensAutoDate: SavedObjectMigrationFn = (doc, context) => { /** * Adds missing timeField arguments to esaggs in the Lens expression */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => { - const expression: string = doc.attributes?.expression; +const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => { + const expression = doc.attributes.expression; + if (!expression) { + return doc; + } try { const ast = fromExpression(expression); @@ -133,27 +173,32 @@ const addTimeFieldToEsaggs: SavedObjectMigrationFn = (doc, context) => } }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const migrations: Record> = { - '7.7.0': doc => { - const newDoc = cloneDeep(doc); - if (newDoc.attributes?.visualizationType === 'lnsXY') { - const datasourceState = newDoc.attributes.state?.datasourceStates?.indexpattern; - const datasourceLayers = datasourceState?.layers ?? {}; - const xyState = newDoc.attributes.state?.visualization; - newDoc.attributes.state.visualization.layers = xyState.layers.map((layer: XYLayerPre77) => { - const layerId = layer.layerId; - const datasource = datasourceLayers[layerId]; - return { - ...layer, - xAccessor: datasource?.columns[layer.xAccessor] ? layer.xAccessor : undefined, - splitAccessor: datasource?.columns[layer.splitAccessor] ? layer.splitAccessor : undefined, - accessors: layer.accessors.filter(accessor => !!datasource?.columns[accessor]), - }; - }) as typeof xyState.layers; - } - return newDoc; - }, +const removeInvalidAccessors: SavedObjectMigrationFn< + LensDocShape, + LensDocShape +> = doc => { + const newDoc = cloneDeep(doc); + if (newDoc.attributes.visualizationType === 'lnsXY') { + const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {}; + const xyState = newDoc.attributes.state.visualization; + (newDoc.attributes as LensDocShape< + XYStatePost77 + >).state.visualization.layers = xyState.layers.map((layer: XYLayerPre77) => { + const layerId = layer.layerId; + const datasource = datasourceLayers[layerId]; + return { + ...layer, + xAccessor: datasource?.columns[layer.xAccessor] ? layer.xAccessor : undefined, + splitAccessor: datasource?.columns[layer.splitAccessor] ? layer.splitAccessor : undefined, + accessors: layer.accessors.filter(accessor => !!datasource?.columns[accessor]), + }; + }); + } + return newDoc; +}; + +export const migrations: SavedObjectMigrationMap = { + '7.7.0': removeInvalidAccessors, // The order of these migrations matter, since the timefield migration relies on the aggConfigs // sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was). '7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context), diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts index a59122d7d630..e2833d5abd0c 100644 --- a/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/create_layer_descriptor.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { AggDescriptor, ColorDynamicOptions, - LabelDynamicOptions, LayerDescriptor, SizeDynamicOptions, StylePropertyField, @@ -80,10 +79,6 @@ function createLayerLabel( metricName = i18n.translate('xpack.maps.observability.durationMetricName', { defaultMessage: 'Duration', }); - } else if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { - metricName = i18n.translate('xpack.maps.observability.slaPercentageMetricName', { - defaultMessage: '% Duration of SLA', - }); } else if (metric === OBSERVABILITY_METRIC_TYPE.COUNT) { metricName = i18n.translate('xpack.maps.observability.countMetricName', { defaultMessage: 'Total', @@ -103,11 +98,6 @@ function createAggDescriptor(metric: OBSERVABILITY_METRIC_TYPE): AggDescriptor { type: AGG_TYPE.AVG, field: 'transaction.duration.us', }; - } else if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { - return { - type: AGG_TYPE.AVG, - field: 'duration_sla_pct', - }; } else if (metric === OBSERVABILITY_METRIC_TYPE.UNIQUE_COUNT) { return { type: AGG_TYPE.UNIQUE_COUNT, @@ -251,16 +241,6 @@ export function createLayerDescriptor({ }, }; - if (metric === OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE) { - styleProperties[VECTOR_STYLES.LABEL_TEXT] = { - type: STYLE_TYPE.DYNAMIC, - options: { - ...(defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT]!.options as LabelDynamicOptions), - field: metricStyleField, - }, - }; - } - return VectorLayer.createDescriptor({ label, query: apmSourceQuery, diff --git a/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx b/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx index 8750034f7469..4a40b257cb51 100644 --- a/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx +++ b/x-pack/plugins/maps/public/layers/solution_layers/observability/metric_select.tsx @@ -11,7 +11,6 @@ import { OBSERVABILITY_LAYER_TYPE } from './layer_select'; export enum OBSERVABILITY_METRIC_TYPE { TRANSACTION_DURATION = 'TRANSACTION_DURATION', - SLA_PERCENTAGE = 'SLA_PERCENTAGE', COUNT = 'COUNT', UNIQUE_COUNT = 'UNIQUE_COUNT', } @@ -23,12 +22,6 @@ const APM_RUM_PERFORMANCE_METRIC_OPTIONS = [ defaultMessage: 'Transaction duraction', }), }, - { - value: OBSERVABILITY_METRIC_TYPE.SLA_PERCENTAGE, - text: i18n.translate('xpack.maps.observability.slaPercentageLabel', { - defaultMessage: 'SLA percentage', - }), - }, ]; const APM_RUM_TRAFFIC_METRIC_OPTIONS = [ diff --git a/x-pack/plugins/ml/common/types/ml_server_info.ts b/x-pack/plugins/ml/common/types/ml_server_info.ts index 26dd1758827b..66142f53add3 100644 --- a/x-pack/plugins/ml/common/types/ml_server_info.ts +++ b/x-pack/plugins/ml/common/types/ml_server_info.ts @@ -18,6 +18,7 @@ export interface MlServerDefaults { export interface MlServerLimits { max_model_memory_limit?: string; + effective_max_model_memory_limit?: string; } export interface MlInfoResponse { diff --git a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js index a3c60a87636f..1853c3d629c3 100644 --- a/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js +++ b/x-pack/plugins/ml/public/application/components/field_type_icon/field_type_icon.js @@ -9,8 +9,6 @@ import React from 'react'; import { EuiToolTip } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { getMLJobTypeAriaLabel } from '../../util/field_types_utils'; import { ML_JOB_FIELD_TYPES } from '../../../../common/constants/field_types'; import { i18n } from '@kbn/i18n'; diff --git a/x-pack/plugins/ml/public/application/components/message_call_out/message_call_out.js b/x-pack/plugins/ml/public/application/components/message_call_out/message_call_out.js index 9a122a0eea70..9a1260ecfdd4 100644 --- a/x-pack/plugins/ml/public/application/components/message_call_out/message_call_out.js +++ b/x-pack/plugins/ml/public/application/components/message_call_out/message_call_out.js @@ -14,8 +14,6 @@ import PropTypes from 'prop-types'; import { EuiCallOut } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { MESSAGE_LEVEL } from '../../../../common/constants/message_levels'; function getCallOutAttributes(message, status) { diff --git a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js index 98e027ec4f36..6001d7cbf6f6 100644 --- a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -30,8 +30,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { getDocLinks } from '../../util/dependency_cache'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { VALIDATION_STATUS } from '../../../../common/constants/validation'; import { getMostSevereMessageStatus } from '../../../../common/util/validation_utils'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index fb3b2b351994..7501fe3d82fc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -91,7 +91,7 @@ export interface FieldSelectionItem { } export interface DfAnalyticsExplainResponse { - field_selection: FieldSelectionItem[]; + field_selection?: FieldSelectionItem[]; memory_estimation: { expected_memory_without_disk: string; expected_memory_with_disk: string; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index 6f9dc694d817..e664a1ddbdbc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -51,6 +51,10 @@ export const useExplorationResults = ( d => !d.includes(`.${FEATURE_IMPORTANCE}.`) && d !== ML__ID_COPY ); + useEffect(() => { + dataGrid.resetPagination(); + }, [JSON.stringify(searchQuery)]); + useEffect(() => { getIndexData(jobConfig, dataGrid, searchQuery); // custom comparison diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index 0d06bc0d4330..75b2f6aa867d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -58,6 +58,10 @@ export const useOutlierData = ( d => !d.includes(`.${FEATURE_INFLUENCE}.`) && d !== ML__ID_COPY ); + useEffect(() => { + dataGrid.resetPagination(); + }, [JSON.stringify(searchQuery)]); + // initialize sorting: reverse sort on outlier score column useEffect(() => { if (jobConfig !== undefined) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx index 92de5ad7be21..85cd70912b41 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.test.tsx @@ -53,7 +53,7 @@ describe('Data Frame Analytics: ', () => { ); const euiFormRows = wrapper.find('EuiFormRow'); - expect(euiFormRows.length).toBe(9); + expect(euiFormRows.length).toBe(10); const row1 = euiFormRows.at(0); expect(row1.find('label').text()).toBe('Job type'); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx index 199100d8b5ab..11052b171845 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/create_analytics_form.tsx @@ -48,6 +48,13 @@ import { } from '../../../../common/analytics'; import { shouldAddAsDepVarOption, OMIT_FIELDS } from './form_options_validation'; +const requiredFieldsErrorText = i18n.translate( + 'xpack.ml.dataframe.analytics.create.requiredFieldsErrorMessage', + { + defaultMessage: 'At least one field must be included in the analysis.', + } +); + export const CreateAnalyticsForm: FC = ({ actions, state }) => { const { services: { docLinks }, @@ -96,6 +103,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta numTopFeatureImportanceValuesValid, previousJobType, previousSourceIndex, + requiredFieldsError, sourceIndex, sourceIndexNameEmpty, sourceIndexNameValid, @@ -158,6 +166,8 @@ export const CreateAnalyticsForm: FC = ({ actions, sta }; const debouncedGetExplainData = debounce(async () => { + const jobTypeOrIndexChanged = + previousSourceIndex !== sourceIndex || previousJobType !== jobType; const shouldUpdateModelMemoryLimit = !firstUpdate.current || !modelMemoryLimit; const shouldUpdateEstimatedMml = !firstUpdate.current || !modelMemoryLimit || estimatedModelMemoryLimit === ''; @@ -167,7 +177,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta } // Reset if sourceIndex or jobType changes (jobType requires dependent_variable to be set - // which won't be the case if switching from outlier detection) - if (previousSourceIndex !== sourceIndex || previousJobType !== jobType) { + if (jobTypeOrIndexChanged) { setFormState({ loadingFieldOptions: true, }); @@ -186,8 +196,21 @@ export const CreateAnalyticsForm: FC = ({ actions, sta setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); } + const fieldSelection: FieldSelectionItem[] | undefined = resp.field_selection; + + let hasRequiredFields = false; + if (fieldSelection) { + for (let i = 0; i < fieldSelection.length; i++) { + const field = fieldSelection[i]; + if (field.is_included === true && field.is_required === false) { + hasRequiredFields = true; + break; + } + } + } + // If sourceIndex has changed load analysis field options again - if (previousSourceIndex !== sourceIndex || previousJobType !== jobType) { + if (jobTypeOrIndexChanged) { const analyzedFieldsOptions: EuiComboBoxOptionOption[] = []; if (resp.field_selection) { @@ -204,21 +227,24 @@ export const CreateAnalyticsForm: FC = ({ actions, sta loadingFieldOptions: false, fieldOptionsFetchFail: false, maxDistinctValuesError: undefined, + requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, }); } else { setFormState({ ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, }); } } catch (e) { let errorMessage; if ( jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - e.message !== undefined && - e.message.includes('status_exception') && - e.message.includes('must have at most') + e.body && + e.body.message !== undefined && + e.body.message.includes('status_exception') && + e.body.message.includes('must have at most') ) { - errorMessage = e.message; + errorMessage = e.body.message; } const fallbackModelMemoryLimit = jobType !== undefined @@ -321,6 +347,7 @@ export const CreateAnalyticsForm: FC = ({ actions, sta excludesOptions: [], previousSourceIndex: sourceIndex, sourceIndex: selectedOptions[0].label || '', + requiredFieldsError: undefined, }); }; @@ -368,6 +395,9 @@ export const CreateAnalyticsForm: FC = ({ actions, sta forceInput.current.dispatchEvent(evt); }, []); + const noSupportetdAnalysisFields = + excludesOptions.length === 0 && fieldOptionsFetchFail === false && !sourceIndexNameEmpty; + return ( @@ -715,18 +745,31 @@ export const CreateAnalyticsForm: FC = ({ actions, sta )} + + + = ({ type, setFormState }) => { previousJobType: type, jobType: value, excludes: [], + requiredFieldsError: undefined, }); }} data-test-subj="mlAnalyticsCreateJobFlyoutJobTypeSelect" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index d55eb14a20e2..1cab42d8ee12 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -124,6 +124,7 @@ export const validateAdvancedEditor = (state: State): State => { createIndexPattern, excludes, maxDistinctValuesError, + requiredFieldsError, } = state.form; const { jobConfig } = state; @@ -330,6 +331,7 @@ export const validateAdvancedEditor = (state: State): State => { state.isValid = maxDistinctValuesError === undefined && + requiredFieldsError === undefined && excludesValid && trainingPercentValid && state.form.modelMemoryLimitUnitValid && @@ -397,6 +399,7 @@ const validateForm = (state: State): State => { maxDistinctValuesError, modelMemoryLimit, numTopFeatureImportanceValuesValid, + requiredFieldsError, } = state.form; const { estimatedModelMemoryLimit } = state; @@ -412,6 +415,7 @@ const validateForm = (state: State): State => { state.isValid = maxDistinctValuesError === undefined && + requiredFieldsError === undefined && !jobTypeEmpty && !mmlValidationResult && !jobIdEmpty && diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 70840a442f6f..8ca985a537b6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -76,6 +76,7 @@ export interface State { numTopFeatureImportanceValuesValid: boolean; previousJobType: null | AnalyticsJobType; previousSourceIndex: EsIndexName | undefined; + requiredFieldsError: string | undefined; sourceIndex: EsIndexName; sourceIndexNameEmpty: boolean; sourceIndexNameValid: boolean; @@ -133,6 +134,7 @@ export const getInitialState = (): State => ({ numTopFeatureImportanceValuesValid: true, previousJobType: null, previousSourceIndex: undefined, + requiredFieldsError: undefined, sourceIndex: '', sourceIndexNameEmpty: true, sourceIndexNameValid: false, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts index 7d966949624c..3b82a34b889b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/utils/utils.ts @@ -5,6 +5,7 @@ */ import { isEqual } from 'lodash'; +// @ts-ignore import numeral from '@elastic/numeral'; import { ml } from '../../../../services/ml_api_service'; import { AnalysisResult, InputOverrides } from '../../../../../../common/types/file_datavisualizer'; diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index cfcba081983c..a46f35cbd4d2 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -1,3 +1,7 @@ +.ml-swimlane-selector { + visibility: hidden; +} + .ml-explorer { width: 100%; display: inline-block; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 86d16776b68e..8fd247981780 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import DragSelect from 'dragselect/dist/ds.min.js'; import { Subject } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { takeUntil } from 'rxjs/operators'; import { EuiFlexGroup, @@ -120,6 +120,7 @@ export class Explorer extends React.Component { disableDragSelectOnMouseLeave = true; dragSelect = new DragSelect({ + selectorClass: 'ml-swimlane-selector', selectables: document.getElementsByClassName('sl-cell'), callback(elements) { if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { @@ -169,12 +170,7 @@ export class Explorer extends React.Component { }; componentDidMount() { - limit$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => d.val) - ) - .subscribe(explorerService.setSwimlaneLimit); + limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 03426869b0cc..2b577c978eb1 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -17,8 +17,6 @@ import d3 from 'd3'; import $ from 'jquery'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { formatHumanReadableDateTime } from '../../util/date_utils'; import { formatValue } from '../../formatters/format_value'; import { getSeverityColor, getSeverityWithLow } from '../../../../common/util/anomaly_utils'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 82041af39ca1..531a24493c96 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -17,8 +17,6 @@ import d3 from 'd3'; import $ from 'jquery'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { formatHumanReadableDateTime } from '../../util/date_utils'; import { formatValue } from '../../formatters/format_value'; import { diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index bf1a3b424edb..8a8a826e1831 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -14,8 +14,6 @@ import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; import { TooltipValue } from '@elastic/charts'; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx index 657f1c6c7af2..cf65419e4bd8 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx @@ -9,8 +9,6 @@ import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; import { SelectLimit } from './select_limit'; -jest.useFakeTimers(); - describe('SelectLimit', () => { test('creates correct initial selected value', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx index 383d07eb7a9f..03e3273b8083 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -9,7 +9,7 @@ */ import React from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { EuiSelect } from '@elastic/eui'; @@ -20,13 +20,13 @@ const euiOptions = limitOptions.map(limit => ({ text: `${limit}`, })); -export const limit$ = new Subject(); export const defaultLimit = limitOptions[1]; +export const limit$ = new BehaviorSubject(defaultLimit); export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => { const limit = useObservable(limit$, defaultLimit); - return [limit, (newLimit: number) => limit$.next(newLimit)]; + return [limit!, (newLimit: number) => limit$.next(newLimit)]; }; export const SelectLimit = () => { 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 f7b0e726ecc5..fa36a0626d63 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 @@ -165,6 +165,15 @@ export function extractJobDetails(job) { items: filterObjects(job.model_size_stats).map(formatValues), }; + const jobTimingStats = { + id: 'jobTimingStats', + title: i18n.translate('xpack.ml.jobsList.jobDetails.jobTimingStatsTitle', { + defaultMessage: 'Job timing stats', + }), + position: 'left', + items: filterObjects(job.timing_stats).map(formatValues), + }; + const datafeedTimingStats = { id: 'datafeedTimingStats', title: i18n.translate('xpack.ml.jobsList.jobDetails.datafeedTimingStatsTitle', { @@ -192,6 +201,7 @@ export function extractJobDetails(job) { datafeed, counts, modelSizeStats, + jobTimingStats, datafeedTimingStats, }; } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js index 9984f3be299a..246a476517ac 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/format_values.js @@ -63,6 +63,12 @@ export function formatValues([key, value]) { // numbers rounded to 3 decimal places case 'average_search_time_per_bucket_ms': case 'exponential_average_search_time_per_hour_ms': + case 'total_bucket_processing_time_ms': + case 'minimum_bucket_processing_time_ms': + case 'maximum_bucket_processing_time_ms': + case 'average_bucket_processing_time_ms': + case 'exponential_average_bucket_processing_time_ms': + case 'exponential_average_bucket_processing_time_per_hour_ms': value = typeof value === 'number' ? roundToDecimalPlace(value, 3).toLocaleString() : value; break; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js index e3f348ad32b0..0375997b86bb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/job_details.js @@ -60,6 +60,7 @@ export class JobDetails extends Component { datafeed, counts, modelSizeStats, + jobTimingStats, datafeedTimingStats, } = extractJobDetails(job); @@ -102,7 +103,7 @@ export class JobDetails extends Component { content: ( ), }, diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index bbfec49ac138..fb75476c48fa 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -369,6 +369,8 @@ class JobService { delete tempJob.open_time; delete tempJob.established_model_memory; delete tempJob.calendars; + delete tempJob.timing_stats; + delete tempJob.forecasts_stats; delete tempJob.analysis_config.use_per_partition_normalization; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap index e5026778fec1..df2e119f511e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -88,6 +88,7 @@ exports[`CalendarForm Renders calendar form 1`] = ` size="xl" /> {isGlobalCalendar === false && ( diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index 64f206679311..eded8460d220 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -15,8 +15,6 @@ import React, { Component } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../../common/constants/states'; import { MESSAGE_LEVEL } from '../../../../../common/constants/message_levels'; import { isJobVersionGte } from '../../../../../common/util/job_utils'; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js index 7dd06268f7f8..3208697073b8 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/run_controls.js @@ -23,8 +23,6 @@ import { EuiToolTip, } from '@elastic/eui'; -// don't use something like plugins/ml/../common -// because it won't work with the jest tests import { JOB_STATE } from '../../../../../common/constants/states'; import { FORECAST_DURATION_MAX_DAYS } from './forecasting_modal'; import { ForecastProgress } from './forecast_progress'; diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index c23d04282281..a9ffb1a5bf57 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -13,7 +13,6 @@ import { MlSetupDependencies, MlStartDependencies, } from './plugin'; -import { getMetricChangeDescription } from './application/formatters/metric_change_description'; export const plugin: PluginInitializer< MlPluginSetup, @@ -22,4 +21,5 @@ export const plugin: PluginInitializer< MlStartDependencies > = () => new MlPlugin(); -export { MlPluginSetup, MlPluginStart, getMetricChangeDescription }; +export { MlPluginSetup, MlPluginStart }; +export * from './shared'; diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts new file mode 100644 index 000000000000..6821cb7ef0f9 --- /dev/null +++ b/x-pack/plugins/ml/public/shared.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from '../common/constants/anomalies'; + +export * from '../common/types/data_recognizer'; +export * from '../common/types/capabilities'; +export * from '../common/types/anomalies'; +export * from '../common/types/modules'; +export * from '../common/types/audit_message'; + +export * from '../common/util/anomaly_utils'; +export * from '../common/util/errors'; +export * from '../common/util/validators'; + +export * from './application/formatters/metric_change_description'; + +export * from './application/components/data_grid'; +export * from './application/data_frame_analytics/common'; diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 175c20bf49c9..4c27854ec719 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -7,5 +7,6 @@ import { PluginInitializerContext } from 'kibana/server'; import { MlServerPlugin } from './plugin'; export { MlPluginSetup, MlPluginStart } from './plugin'; +export * from './shared'; export const plugin = (ctx: PluginInitializerContext) => new MlServerPlugin(ctx); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts similarity index 56% rename from x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js rename to x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index a0dacc38e583..f5daadfe86be 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/__tests__/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { estimateBucketSpanFactory } from '../bucket_span_estimator'; +import { APICaller } from 'kibana/server'; + +import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; + +import { estimateBucketSpanFactory, BucketSpanEstimatorData } from './bucket_span_estimator'; // Mock callWithRequest with the ability to simulate returning different // permission settings. On each call using `ml.privilegeCheck` we retrieve @@ -14,7 +17,7 @@ import { estimateBucketSpanFactory } from '../bucket_span_estimator'; // sufficient permissions should be returned, the second time insufficient // permissions. const permissions = [false, true]; -const callWithRequest = method => { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { if (method === 'ml.privilegeCheck') { resolve({ @@ -28,34 +31,19 @@ const callWithRequest = method => { return; } resolve({}); - }); + }) as Promise; }; -const callWithInternalUser = () => { +const callWithInternalUser: APICaller = () => { return new Promise(resolve => { resolve({}); - }); + }) as Promise; }; -// mock xpack_main plugin -function mockXpackMainPluginFactory(isEnabled = false, licenseType = 'platinum') { - return { - info: { - isAvailable: () => true, - feature: () => ({ - isEnabled: () => isEnabled, - }), - license: { - getType: () => licenseType, - }, - }, - }; -} - // mock configuration to be passed to the estimator -const formConfig = { - aggTypes: ['count'], - duration: {}, +const formConfig: BucketSpanEstimatorData = { + aggTypes: [ES_AGGREGATION.COUNT], + duration: { start: 0, end: 1 }, fields: [null], index: '', query: { @@ -64,13 +52,15 @@ const formConfig = { must_not: [], }, }, + splitField: undefined, + timeField: undefined, }; describe('ML - BucketSpanEstimator', () => { it('call factory', () => { expect(function() { - estimateBucketSpanFactory(callWithRequest, callWithInternalUser); - }).to.not.throwError('Not initialized.'); + estimateBucketSpanFactory(callWithRequest, callWithInternalUser, false); + }).not.toThrow('Not initialized.'); }); it('call factory and estimator with security disabled', done => { @@ -78,44 +68,29 @@ describe('ML - BucketSpanEstimator', () => { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory() + true ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); - it('call factory and estimator with security enabled and sufficient permissions.', done => { + it('call factory and estimator with security enabled.', done => { expect(function() { const estimateBucketSpan = estimateBucketSpanFactory( callWithRequest, callWithInternalUser, - mockXpackMainPluginFactory(true) + false ); estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Unable to retrieve cluster setting search.max_buckets'); + expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); done(); }); - }).to.not.throwError('Not initialized.'); - }); - - it('call factory and estimator with security enabled and insufficient permissions.', done => { - expect(function() { - const estimateBucketSpan = estimateBucketSpanFactory( - callWithRequest, - callWithInternalUser, - mockXpackMainPluginFactory(true) - ); - - estimateBucketSpan(formConfig).catch(catchData => { - expect(catchData).to.be('Insufficient permissions to call bucket span estimation.'); - done(); - }); - }).to.not.throwError('Not initialized.'); + }).not.toThrow('Not initialized.'); }); }); diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index cd61dd9eddcd..1cc2a07ddbc8 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -9,6 +9,7 @@ import { APICaller } from 'kibana/server'; import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; +import { MlInfoResponse } from '../../../common/types/ml_server_info'; interface ModelMemoryEstimationResult { /** @@ -139,15 +140,9 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) latestMs: number, allowMMLGreaterThanMax = false ): Promise { - let maxModelMemoryLimit; - try { - const resp = await callAsCurrentUser('ml.info'); - if (resp?.limits?.max_model_memory_limit !== undefined) { - maxModelMemoryLimit = resp.limits.max_model_memory_limit.toUpperCase(); - } - } catch (e) { - throw new Error('Unable to retrieve max model memory limit'); - } + const info = await callAsCurrentUser('ml.info'); + const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); + const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); const { overallCardinality, maxBucketCardinality } = await getCardinalities( analysisConfig, @@ -168,17 +163,32 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) }) ).model_memory_estimate.toUpperCase(); - let modelMemoryLimit: string = estimatedModelMemoryLimit; + let modelMemoryLimit = estimatedModelMemoryLimit; + let mmlCappedAtMax = false; // if max_model_memory_limit has been set, // make sure the estimated value is not greater than it. - if (!allowMMLGreaterThanMax && maxModelMemoryLimit !== undefined) { - // @ts-ignore - const maxBytes = numeral(maxModelMemoryLimit).value(); + if (allowMMLGreaterThanMax === false) { // @ts-ignore const mmlBytes = numeral(estimatedModelMemoryLimit).value(); - if (mmlBytes > maxBytes) { + if (maxModelMemoryLimit !== undefined) { + // @ts-ignore + const maxBytes = numeral(maxModelMemoryLimit).value(); + if (mmlBytes > maxBytes) { + // @ts-ignore + modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; + mmlCappedAtMax = true; + } + } + + // if we've not already capped the estimated mml at the hard max server setting + // ensure that the estimated mml isn't greater than the effective max mml + if (mmlCappedAtMax === false && effectiveMaxModelMemoryLimit !== undefined) { // @ts-ignore - modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; + const effectiveMaxMmlBytes = numeral(effectiveMaxModelMemoryLimit).value(); + if (mmlBytes > effectiveMaxMmlBytes) { + // @ts-ignore + modelMemoryLimit = `${Math.floor(effectiveMaxMmlBytes / numeral('1MB').value())}MB`; + } } } @@ -186,6 +196,7 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) estimatedModelMemoryLimit, modelMemoryLimit, ...(maxModelMemoryLimit ? { maxModelMemoryLimit } : {}), + ...(effectiveMaxModelMemoryLimit ? { effectiveMaxModelMemoryLimit } : {}), }; }; } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_high_count_process_events_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_high_count_process_events_ecs.json index d8c970e17941..c792b981df30 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_high_count_process_events_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_high_count_process_events_ecs.json @@ -30,7 +30,7 @@ { "url_name": "Process rate", "time_range": "1h", - "url_value": "kibana#/dashboard/ml_auditbeat_docker_process_event_rate_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:container.runtime,negate:!f,params:(query:docker),type:phrase,value:docker),query:(match:(container.runtime:(query:docker,type:phrase)))),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027container.name:\u0022$container.name$\u0022\u0027))" + "url_value": "kibana#/dashboard/ml_auditbeat_docker_process_event_rate_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!f,params:(query:docker),type:phrase,value:docker),query:(match:(container.runtime:(query:docker,type:phrase)))),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027container.name:\u0022$container.name$\u0022\u0027))" }, { "url_name": "Raw data", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_rare_process_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_rare_process_activity_ecs.json index 76e3c8026c63..b3f02ae5a6bf 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_rare_process_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_docker_ecs/ml/docker_rare_process_activity_ecs.json @@ -30,7 +30,7 @@ { "url_name": "Process explorer", "time_range": "1h", - "url_value": "kibana#/dashboard/ml_auditbeat_docker_process_explorer_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:container.runtime,negate:!f,params:(query:docker),type:phrase,value:docker),query:(match:(container.runtime:(query:docker,type:phrase)))),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027container.name:\u0022$container.name$\u0022\u0027))" + "url_value": "kibana#/dashboard/ml_auditbeat_docker_process_explorer_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!f,params:(query:docker),type:phrase,value:docker),query:(match:(container.runtime:(query:docker,type:phrase)))),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027container.name:\u0022$container.name$\u0022\u0027))" }, { "url_name": "Raw data", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json index 487bee531187..0e9336507b46 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_high_count_process_events_ecs.json @@ -29,7 +29,7 @@ { "url_name": "Process rate", "time_range": "1h", - "url_value": "kibana#/dashboard/ml_auditbeat_hosts_process_event_rate_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" + "url_value": "kibana#/dashboard/ml_auditbeat_hosts_process_event_rate_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" }, { "url_name": "Raw data", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json index 9ba6859bfa16..4dd1409b71c7 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/auditbeat_process_hosts_ecs/ml/hosts_rare_process_activity_ecs.json @@ -30,7 +30,7 @@ { "url_name": "Process explorer", "time_range": "1h", - "url_value": "kibana#/dashboard/ml_auditbeat_hosts_process_explorer_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:INDEX_PATTERN_ID,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" + "url_value": "kibana#/dashboard/ml_auditbeat_hosts_process_explorer_ecs?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:event.module,negate:!f,params:(query:auditd),type:phrase,value:auditd),query:(match:(event.module:(query:auditd,type:phrase)))),('$state':(store:appState),exists:(field:container.runtime),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:container.runtime,negate:!t,type:exists,value:exists)),('$state':(store:appState),exists:(field:auditd.data.syscall),meta:(alias:!n,disabled:!f,index:\u0027INDEX_PATTERN_ID\u0027,key:auditd.data.syscall,negate:!f,type:exists,value:exists))),query:(language:kuery,query:\u0027host.name:\u0022$host.name$\u0022\u0027))" }, { "url_name": "Raw data", diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/high_sum_total_sales.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/high_sum_total_sales.json index e0230e2a0637..c3d401085f7a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/high_sum_total_sales.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/sample_data_ecommerce/ml/high_sum_total_sales.json @@ -27,7 +27,7 @@ "custom_urls": [ { "url_name": "Raw data", - "url_value": "kibana#/discover?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,query:(language:kuery,query:\u0027customer_full_name.keyword:\u0022$customer_full_name.keyword$\u0022\u0027),sort:!('@timestamp',desc))" + "url_value": "kibana#/discover?_g=(time:(from:\u0027$earliest$\u0027,mode:absolute,to:\u0027$latest$\u0027))&_a=(index:\u0027ff959d40-b880-11e8-a6d9-e546fe2bba5f\u0027,query:(language:kuery,query:\u0027customer_full_name.keyword:\u0022$customer_full_name.keyword$\u0022\u0027),sort:!('@timestamp',desc))" }, { "url_name": "Data dashboard", diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 6024ecf4925e..225cd43e411a 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -328,7 +328,7 @@ export function jobsProvider(callAsCurrentUser: APICaller) { // create jobs objects containing job stats, datafeeds, datafeed stats and calendars if (jobResults && jobResults.jobs) { jobResults.jobs.forEach(job => { - const tempJob = job as CombinedJobWithStats; + let tempJob = job as CombinedJobWithStats; const calendars: string[] = [ ...(calendarsByJobId[tempJob.job_id] || []), @@ -341,9 +341,7 @@ export function jobsProvider(callAsCurrentUser: APICaller) { if (jobStatsResults && jobStatsResults.jobs) { const jobStats = jobStatsResults.jobs.find(js => js.job_id === tempJob.job_id); if (jobStats !== undefined) { - tempJob.state = jobStats.state; - tempJob.data_counts = jobStats.data_counts; - tempJob.model_size_stats = jobStats.model_size_stats; + tempJob = { ...tempJob, ...jobStats }; if (jobStats.node) { tempJob.node = jobStats.node; } diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_cardinality.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_cardinality.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_farequote_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_farequote_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_field_caps.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_field_caps.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_it_search_response.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_it_search_response.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_field_nested.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_field_nested.json diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json b/x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json similarity index 100% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/mock_time_range.json rename to x-pack/plugins/ml/server/models/job_validation/__mocks__/mock_time_range.json diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts index 33f5d5ec95fa..6a9a7a0c1339 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.d.ts @@ -6,14 +6,19 @@ import { APICaller } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; + +import { DeepPartial } from '../../../common/types/common'; + import { validateJobSchema } from '../../routes/schemas/job_validation_schema'; -type ValidateJobPayload = TypeOf; +import { ValidationMessage } from './messages'; + +export type ValidateJobPayload = TypeOf; export function validateJob( callAsCurrentUser: APICaller, - payload: ValidateJobPayload, - kbnVersion: string, - callAsInternalUser: APICaller, - isSecurityDisabled: boolean -): string[]; + payload?: DeepPartial, + kbnVersion?: string, + callAsInternalUser?: APICaller, + isSecurityDisabled?: boolean +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts similarity index 77% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js rename to x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index 726a8e8d8db8..ca127f43d08a 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/job_validation.js +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -4,16 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateJob } from '../job_validation'; +import { APICaller } from 'kibana/server'; + +import { validateJob } from './job_validation'; // mock callWithRequest -const callWithRequest = () => { +const callWithRequest: APICaller = (method: string) => { return new Promise(resolve => { + if (method === 'fieldCaps') { + resolve({ fields: [] }); + return; + } resolve({}); - }); + }) as Promise; }; +// Note: The tests cast `payload` as any +// so we can simulate possible runtime payloads +// that don't satisfy the TypeScript specs. describe('ML - validateJob', () => { it('calling factory without payload throws an error', done => { validateJob(callWithRequest).then( @@ -61,7 +69,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_empty', 'detectors_empty', 'bucket_span_empty', @@ -70,10 +78,14 @@ describe('ML - validateJob', () => { }); }); - const jobIdTests = (testIds, messageId) => { + const jobIdTests = (testIds: string[], messageId: string) => { const promises = testIds.map(id => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.job_id = id; + const payload = { + job: { + analysis_config: { detectors: [] }, + job_id: id, + }, + }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for jobIdTests.'); }); @@ -81,19 +93,21 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; - const jobGroupIdTest = (testIds, messageId) => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.groups = testIds; + const jobGroupIdTest = (testIds: string[], messageId: string) => { + const payload = { job: { analysis_config: { detectors: [] }, groups: testIds } }; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(ids.includes(messageId)).toBe(true); }); }; @@ -126,10 +140,9 @@ describe('ML - validateJob', () => { return jobGroupIdTest(validTestIds, 'job_group_id_valid'); }); - const bucketSpanFormatTests = (testFormats, messageId) => { + const bucketSpanFormatTests = (testFormats: string[], messageId: string) => { const promises = testFormats.map(format => { - const payload = { job: { analysis_config: { detectors: [] } } }; - payload.job.analysis_config.bucket_span = format; + const payload = { job: { analysis_config: { bucket_span: format, detectors: [] } } }; return validateJob(callWithRequest, payload).catch(() => { new Error('Promise should not fail for bucketSpanFormatTests.'); }); @@ -137,8 +150,11 @@ describe('ML - validateJob', () => { return Promise.all(promises).then(testResults => { testResults.forEach(messages => { - const ids = messages.map(m => m.id); - expect(ids.includes(messageId)).to.equal(true); + expect(Array.isArray(messages)).toBe(true); + if (Array.isArray(messages)) { + const ids = messages.map(m => m.id); + expect(ids.includes(messageId)).toBe(true); + } }); }); }; @@ -152,7 +168,7 @@ describe('ML - validateJob', () => { }); it('at least one detector function is empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); @@ -165,19 +181,19 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_empty')).to.equal(true); + expect(ids.includes('detectors_function_empty')).toBe(true); }); }); it('detector function is not empty', () => { - const payload = { job: { analysis_config: { detectors: [] } } }; + const payload = { job: { analysis_config: { detectors: [] as Array<{ function?: string }> } } }; payload.job.analysis_config.detectors.push({ function: 'count', }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('detectors_function_not_empty')).to.equal(true); + expect(ids.includes('detectors_function_not_empty')).toBe(true); }); }); @@ -189,7 +205,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_invalid')).to.equal(true); + expect(ids.includes('index_fields_invalid')).toBe(true); }); }); @@ -201,11 +217,11 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids.includes('index_fields_valid')).to.equal(true); + expect(ids.includes('index_fields_valid')).toBe(true); }); }); - const getBasicPayload = () => ({ + const getBasicPayload = (): any => ({ job: { job_id: 'test', analysis_config: { @@ -214,7 +230,7 @@ describe('ML - validateJob', () => { { function: 'count', }, - ], + ] as Array<{ function: string; by_field_name?: string; partition_field_name?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -224,7 +240,7 @@ describe('ML - validateJob', () => { }); it('throws an error because job.analysis_config.influencers is not an Array', done => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; delete payload.job.analysis_config.influencers; validateJob(callWithRequest, payload).then( @@ -237,11 +253,11 @@ describe('ML - validateJob', () => { }); it('detect duplicate detectors', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; payload.job.analysis_config.detectors.push({ function: 'count' }); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'detectors_duplicates', @@ -253,7 +269,7 @@ describe('ML - validateJob', () => { }); it('dedupe duplicate messages', () => { - const payload = getBasicPayload(); + const payload = getBasicPayload() as any; // in this test setup, the following configuration passes // the duplicate detectors check, but would return the same // 'field_not_aggregatable' message for both detectors. @@ -264,7 +280,7 @@ describe('ML - validateJob', () => { ]; return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -274,11 +290,12 @@ describe('ML - validateJob', () => { }); }); - it('basic validation passes, extended checks return some messages', () => { + // Failing https://github.com/elastic/kibana/issues/65865 + it.skip('basic validation passes, extended checks return some messages', () => { const payload = getBasicPayload(); return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -287,8 +304,9 @@ describe('ML - validateJob', () => { }); }); - it('categorization job using mlcategory passes aggregatable field check', () => { - const payload = { + // Failing https://github.com/elastic/kibana/issues/65866 + it.skip('categorization job using mlcategory passes aggregatable field check', () => { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -310,7 +328,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -322,7 +340,7 @@ describe('ML - validateJob', () => { }); it('non-existent field reported as non aggregatable', () => { - const payload = { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -343,7 +361,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -353,8 +371,9 @@ describe('ML - validateJob', () => { }); }); - it('script field not reported as non aggregatable', () => { - const payload = { + // Failing https://github.com/elastic/kibana/issues/65867 + it.skip('script field not reported as non aggregatable', () => { + const payload: any = { job: { job_id: 'categorization_test', analysis_config: { @@ -385,7 +404,7 @@ describe('ML - validateJob', () => { return validateJob(callWithRequest, payload).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([ + expect(ids).toStrictEqual([ 'job_id_valid', 'detectors_function_not_empty', 'index_fields_valid', @@ -399,19 +418,19 @@ describe('ML - validateJob', () => { // the following two tests validate the correct template rendering of // urls in messages with {{version}} in them to be replaced with the // specified version. (defaulting to 'current') - const docsTestPayload = getBasicPayload(); + const docsTestPayload = getBasicPayload() as any; docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }]; it('creates a docs url pointing to the current docs version', () => { return validateJob(callWithRequest, docsTestPayload).then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/current/')).not.to.be(-1); + expect(message.url.search('/current/')).not.toBe(-1); }); }); it('creates a docs url pointing to the master docs version', () => { return validateJob(callWithRequest, docsTestPayload, 'master').then(messages => { const message = messages[messages.findIndex(m => m.id === 'field_not_aggregatable')]; - expect(message.url.search('/master/')).not.to.be(-1); + expect(message.url.search('/master/')).not.toBe(-1); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.d.ts b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts new file mode 100644 index 000000000000..772d78b4187d --- /dev/null +++ b/x-pack/plugins/ml/server/models/job_validation/messages.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ValidationMessage { + id: string; + url: string; +} diff --git a/x-pack/plugins/ml/server/models/job_validation/messages.js b/x-pack/plugins/ml/server/models/job_validation/messages.js index 3fd90d0a356a..6cdbc457e6ad 100644 --- a/x-pack/plugins/ml/server/models/job_validation/messages.js +++ b/x-pack/plugins/ml/server/models/job_validation/messages.js @@ -433,6 +433,17 @@ export const getMessages = () => { } ), }, + mml_greater_than_effective_max_mml: { + status: 'WARNING', + text: i18n.translate( + 'xpack.ml.models.jobValidation.messages.mmlGreaterThanEffectiveMaxMmlMessage', + { + defaultMessage: + 'Job will not be able to run in the current cluster because model memory limit is higher than {effectiveMaxModelMemoryLimit}.', + values: { effectiveMaxModelMemoryLimit: '{{effectiveMaxModelMemoryLimit}}' }, + } + ), + }, mml_greater_than_max_mml: { status: 'ERROR', text: i18n.translate('xpack.ml.models.jobValidation.messages.mmlGreaterThanMaxMmlMessage', { diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts similarity index 81% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js rename to x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts index 3dc2bee1e870..4001697d7432 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateBucketSpan } from '../validate_bucket_span'; -import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../../common/constants/validation'; +import { SKIP_BUCKET_SPAN_ESTIMATION } from '../../../common/constants/validation'; + +import { ValidationMessage } from './messages'; +// @ts-ignore +import { validateBucketSpan } from './validate_bucket_span'; // farequote2017 snapshot snapshot mock search response // it returns a mock for the response of PolledDataChecker's search request // to get an aggregation of non_empty_buckets with an interval of 1m. // this allows us to test bucket span estimation. -import mockFareQuoteSearchResponse from './mock_farequote_search_response'; +import mockFareQuoteSearchResponse from './__mocks__/mock_farequote_search_response.json'; // it_ops_app_logs 2017 snapshot mock search response // sparse data with a low number of buckets -import mockItSearchResponse from './mock_it_search_response'; +import mockItSearchResponse from './__mocks__/mock_it_search_response.json'; // mock callWithRequestFactory -const callWithRequestFactory = mockSearchResponse => { +const callWithRequestFactory = (mockSearchResponse: any) => { return () => { return new Promise(resolve => { resolve(mockSearchResponse); @@ -86,17 +88,17 @@ describe('ML - validateBucketSpan', () => { }; return validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); } ); }); - const getJobConfig = bucketSpan => ({ + const getJobConfig = (bucketSpan: string) => ({ analysis_config: { bucket_span: bucketSpan, - detectors: [], + detectors: [] as Array<{ function?: string }>, influencers: [], }, data_description: { time_field: '@timestamp' }, @@ -111,9 +113,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); @@ -125,9 +127,9 @@ describe('ML - validateBucketSpan', () => { callWithRequestFactory(mockFareQuoteSearchResponse), job, duration - ).then(messages => { + ).then((messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['bucket_span_high']); + expect(ids).toStrictEqual(['bucket_span_high']); }); }); @@ -135,14 +137,18 @@ describe('ML - validateBucketSpan', () => { return; } - const testBucketSpan = (bucketSpan, mockSearchResponse, test) => { + const testBucketSpan = ( + bucketSpan: string, + mockSearchResponse: any, + test: (ids: string[]) => void + ) => { const job = getJobConfig(bucketSpan); job.analysis_config.detectors.push({ function: 'count', }); return validateBucketSpan(callWithRequestFactory(mockSearchResponse), job, {}).then( - messages => { + (messages: ValidationMessage[]) => { const ids = messages.map(m => m.id); test(ids); } @@ -151,13 +157,13 @@ describe('ML - validateBucketSpan', () => { it('farequote count detector, bucket span estimation matches 15m', () => { return testBucketSpan('15m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); it('farequote count detector, bucket span estimation does not match 1m', () => { return testBucketSpan('1m', mockFareQuoteSearchResponse, ids => { - expect(ids).to.eql(['bucket_span_estimation_mismatch']); + expect(ids).toStrictEqual(['bucket_span_estimation_mismatch']); }); }); @@ -167,7 +173,7 @@ describe('ML - validateBucketSpan', () => { // should result in a lower bucket span estimation. it('it_ops_app_logs count detector, bucket span estimation matches 6h', () => { return testBucketSpan('6h', mockItSearchResponse, ids => { - expect(ids).to.eql(['success_bucket_span']); + expect(ids).toStrictEqual(['success_bucket_span']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts index 22d2fec0bedd..2fad1252e644 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.d.ts @@ -7,4 +7,7 @@ import { APICaller } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -export function validateCardinality(callAsCurrentUser: APICaller, job: CombinedJob): any[]; +export function validateCardinality( + callAsCurrentUser: APICaller, + job?: CombinedJob +): Promise; diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts similarity index 69% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js rename to x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts index 9617982a66b0..e5111629f118 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_cardinality.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts @@ -5,11 +5,15 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { validateCardinality } from '../validate_cardinality'; -import mockFareQuoteCardinality from './mock_farequote_cardinality'; -import mockFieldCaps from './mock_field_caps'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import mockFareQuoteCardinality from './__mocks__/mock_farequote_cardinality.json'; +import mockFieldCaps from './__mocks__/mock_field_caps.json'; + +import { validateCardinality } from './validate_cardinality'; const mockResponses = { search: mockFareQuoteCardinality, @@ -17,8 +21,8 @@ const mockResponses = { }; // mock callWithRequestFactory -const callWithRequestFactory = (responses, fail = false) => { - return requestName => { +const callWithRequestFactory = (responses: Record, fail = false): APICaller => { + return (requestName: string) => { return new Promise((resolve, reject) => { const response = responses[requestName]; if (fail) { @@ -26,7 +30,7 @@ const callWithRequestFactory = (responses, fail = false) => { } else { resolve(response); } - }); + }) as Promise; }; }; @@ -39,21 +43,23 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), {}).then( + validateCardinality(callWithRequestFactory(mockResponses), {} as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateCardinality(callWithRequestFactory(mockResponses), { analysis_config: {} }).then( + validateCardinality(callWithRequestFactory(mockResponses), { + analysis_config: {}, + } as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #3, missing datafeed_config.indices', done => { - const job = { analysis_config: {}, datafeed_config: {} }; + const job = { analysis_config: {}, datafeed_config: {} } as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -61,7 +67,10 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #4, missing data_description', done => { - const job = { analysis_config: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -69,7 +78,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #5, missing data_description.time_field', done => { - const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; + const job = ({ + analysis_config: {}, + data_description: {}, + datafeed_config: { indices: [] }, + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -77,11 +90,11 @@ describe('ML - validateCardinality', () => { }); it('called with non-valid job argument #6, missing analysis_config.influencers', done => { - const job = { + const job = ({ analysis_config: {}, datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, - }; + } as unknown) as CombinedJob; validateCardinality(callWithRequestFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() @@ -89,21 +102,21 @@ describe('ML - validateCardinality', () => { }); it('minimum job configuration to pass cardinality check code', () => { - const job = { + const job = ({ analysis_config: { detectors: [], influencers: [] }, data_description: { time_field: '@timestamp' }, datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); - const getJobConfig = fieldName => ({ + const getJobConfig = (fieldName: string) => ({ analysis_config: { detectors: [ { @@ -119,11 +132,18 @@ describe('ML - validateCardinality', () => { }, }); - const testCardinality = (fieldName, cardinality, test) => { + const testCardinality = ( + fieldName: string, + cardinality: number, + test: (ids: string[]) => void + ) => { const job = getJobConfig(fieldName); const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job, {}).then(messages => { + return validateCardinality( + callWithRequestFactory(mockCardinality), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); test(ids); }); @@ -132,26 +152,34 @@ describe('ML - validateCardinality', () => { it(`field '_source' not aggregatable`, () => { const job = getJobConfig('partition_field_name'); job.analysis_config.detectors[0].partition_field_name = '_source'; - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); + expect(ids).toStrictEqual(['field_not_aggregatable']); }); }); it(`field 'airline' aggregatable`, () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory(mockResponses), job).then(messages => { + return validateCardinality( + callWithRequestFactory(mockResponses), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('field not aggregatable', () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory({}), job).then(messages => { - const ids = messages.map(m => m.id); - expect(ids).to.eql(['field_not_aggregatable']); - }); + return validateCardinality(callWithRequestFactory({}), (job as unknown) as CombinedJob).then( + messages => { + const ids = messages.map(m => m.id); + expect(ids).toStrictEqual(['field_not_aggregatable']); + } + ); }); it('fields not aggregatable', () => { @@ -160,107 +188,110 @@ describe('ML - validateCardinality', () => { function: 'count', partition_field_name: 'airline', }); - return validateCardinality(callWithRequestFactory({}, true), job).then(messages => { + return validateCardinality( + callWithRequestFactory({}, true), + (job as unknown) as CombinedJob + ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['fields_not_aggregatable']); + expect(ids).toStrictEqual(['fields_not_aggregatable']); }); }); it('valid partition field cardinality', () => { return testCardinality('partition_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high partition field cardinality', () => { return testCardinality('partition_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_partition_field']); + expect(ids).toStrictEqual(['cardinality_partition_field']); }); }); it('valid by field cardinality', () => { return testCardinality('by_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too high by field cardinality', () => { return testCardinality('by_field_name', 1001, ids => { - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it('valid over field cardinality', () => { return testCardinality('over_field_name', 50, ids => { - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it('too low over field cardinality', () => { return testCardinality('over_field_name', 9, ids => { - expect(ids).to.eql(['cardinality_over_field_low']); + expect(ids).toStrictEqual(['cardinality_over_field_low']); }); }); it('too high over field cardinality', () => { return testCardinality('over_field_name', 1000001, ids => { - expect(ids).to.eql(['cardinality_over_field_high']); + expect(ids).toStrictEqual(['cardinality_over_field_high']); }); }); const cardinality = 10000; it(`disabled model_plot, over field cardinality of ${cardinality} doesn't trigger a warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_cardinality']); + expect(ids).toStrictEqual(['success_cardinality']); }); }); it(`enabled model_plot, over field cardinality of ${cardinality} triggers a model plot warning`, () => { - const job = getJobConfig('over_field_name'); + const job = (getJobConfig('over_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high']); + expect(ids).toStrictEqual(['cardinality_model_plot_high']); }); }); it(`disabled model_plot, by field cardinality of ${cardinality} triggers a field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); it(`enabled model_plot, by field cardinality of ${cardinality} triggers a model plot warning and field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_model_plot_high', 'cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_model_plot_high', 'cardinality_by_field']); }); }); it(`enabled model_plot with terms, by field cardinality of ${cardinality} triggers just field cardinality warning`, () => { - const job = getJobConfig('by_field_name'); + const job = (getJobConfig('by_field_name') as unknown) as CombinedJob; job.model_plot_config = { enabled: true, terms: 'AAL,AAB' }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality(callWithRequestFactory(mockCardinality), job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['cardinality_by_field']); + expect(ids).toStrictEqual(['cardinality_by_field']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts similarity index 63% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts index 06b2e5205fdb..df3310ad9f5e 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import { validateInfluencers } from '../validate_influencers'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { validateInfluencers } from './validate_influencers'; describe('ML - validateInfluencers', () => { it('called without arguments throws an error', done => { - validateInfluencers().then( + validateInfluencers( + (undefined as unknown) as APICaller, + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing analysis_config', done => { - validateInfluencers(undefined, {}).then( + validateInfluencers((undefined as unknown) as APICaller, ({} as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -28,7 +34,7 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -40,25 +46,29 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers(undefined, job).then( + validateInfluencers((undefined as unknown) as APICaller, (job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); - const getJobConfig = (influencers = [], detectors = []) => ({ - analysis_config: { detectors, influencers }, - data_description: { time_field: '@timestamp' }, - datafeed_config: { - indices: [], - }, - }); + const getJobConfig: ( + influencers?: string[], + detectors?: CombinedJob['analysis_config']['detectors'] + ) => CombinedJob = (influencers = [], detectors = []) => + (({ + analysis_config: { detectors, influencers }, + data_description: { time_field: '@timestamp' }, + datafeed_config: { + indices: [], + }, + } as unknown) as CombinedJob); it('success_influencer', () => { const job = getJobConfig(['airline']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_influencers']); + expect(ids).toStrictEqual(['success_influencers']); }); }); @@ -69,31 +79,30 @@ describe('ML - validateInfluencers', () => { { detector_description: 'count', function: 'count', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql([]); + expect(ids).toStrictEqual([]); }); }); it('influencer_low', () => { const job = getJobConfig(); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low']); + expect(ids).toStrictEqual(['influencer_low']); }); }); it('influencer_high', () => { const job = getJobConfig(['i1', 'i2', 'i3', 'i4']); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_high']); + expect(ids).toStrictEqual(['influencer_high']); }); }); @@ -105,14 +114,13 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'airline', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['influencer_low_suggestion']); + expect(ids).toStrictEqual(['influencer_low_suggestion']); }); }); @@ -124,27 +132,24 @@ describe('ML - validateInfluencers', () => { detector_description: 'count', function: 'count', partition_field_name: 'partition_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', by_field_name: 'by_field', - rules: [], detector_index: 0, }, { detector_description: 'count', function: 'count', over_field_name: 'over_field', - rules: [], detector_index: 0, }, ] ); - return validateInfluencers(undefined, job).then(messages => { - expect(messages).to.eql([ + return validateInfluencers((undefined as unknown) as APICaller, job).then(messages => { + expect(messages).toStrictEqual([ { id: 'influencer_low_suggestions', influencerSuggestion: '["partition_field","by_field","over_field"]', diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts similarity index 89% rename from x-pack/plugins/ml/server/models/job_validation/validate_influencers.js rename to x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts index 60fd5c37b995..e54ffc4586a8 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts @@ -4,19 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + import { validateJobObject } from './validate_job_object'; const INFLUENCER_LOW_THRESHOLD = 0; const INFLUENCER_HIGH_THRESHOLD = 4; const DETECTOR_FIELD_NAMES_THRESHOLD = 1; -export async function validateInfluencers(callWithRequest, job) { +export async function validateInfluencers(callWithRequest: APICaller, job: CombinedJob) { validateJobObject(job); const messages = []; const influencers = job.analysis_config.influencers; - const detectorFieldNames = []; + const detectorFieldNames: string[] = []; job.analysis_config.detectors.forEach(d => { if (d.by_field_name) { detectorFieldNames.push(d.by_field_name); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index 6b5d5614325b..bf88716181bb 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -24,6 +24,7 @@ describe('ML - validateModelMemoryLimit', () => { }, limits: { max_model_memory_limit: '30mb', + effective_max_model_memory_limit: '40mb', }, }; @@ -211,6 +212,30 @@ describe('ML - validateModelMemoryLimit', () => { }); }); + it('Called with no duration or split and mml above limit, no max setting', () => { + const job = getJobConfig(); + const duration = undefined; + // @ts-ignore + job.analysis_limits.model_memory_limit = '31mb'; + + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { + const ids = messages.map(m => m.id); + expect(ids).toEqual([]); + }); + }); + + it('Called with no duration or split and mml above limit, no max setting, above effective max mml', () => { + const job = getJobConfig(); + const duration = undefined; + // @ts-ignore + job.analysis_limits.model_memory_limit = '41mb'; + + return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then(messages => { + const ids = messages.map(m => m.id); + expect(ids).toEqual(['mml_greater_than_effective_max_mml']); + }); + }); + it('Called with small number of detectors, so estimated mml is under specified mml, no max setting', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts index 16a48addfeaf..5c3250af6ef4 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts @@ -10,6 +10,7 @@ import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { validateJobObject } from './validate_job_object'; import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; import { ALLOWED_DATA_UNITS } from '../../../common/constants/validation'; +import { MlInfoResponse } from '../../../common/types/ml_server_info'; // The minimum value the backend expects is 1MByte const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; @@ -50,9 +51,9 @@ export async function validateModelMemoryLimit( // retrieve the max_model_memory_limit value from the server // this will be unset unless the user has set this on their cluster - const maxModelMemoryLimit: string | undefined = ( - await callWithRequest('ml.info') - )?.limits?.max_model_memory_limit?.toUpperCase(); + const info = await callWithRequest('ml.info'); + const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); + const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); if (runCalcModelMemoryTest) { const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(callWithRequest)( @@ -113,17 +114,35 @@ export async function validateModelMemoryLimit( // if max_model_memory_limit has been set, // make sure the user defined MML is not greater than it - if (maxModelMemoryLimit !== undefined && mml !== null) { - // @ts-ignore - const maxMmlBytes = numeral(maxModelMemoryLimit).value(); + if (mml !== null) { + let maxMmlExceeded = false; // @ts-ignore const mmlBytes = numeral(mml).value(); - if (mmlBytes > maxMmlBytes) { - messages.push({ - id: 'mml_greater_than_max_mml', - maxModelMemoryLimit, - mml, - }); + + if (maxModelMemoryLimit !== undefined) { + // @ts-ignore + const maxMmlBytes = numeral(maxModelMemoryLimit).value(); + if (mmlBytes > maxMmlBytes) { + maxMmlExceeded = true; + messages.push({ + id: 'mml_greater_than_max_mml', + maxModelMemoryLimit, + mml, + }); + } + } + + if (effectiveMaxModelMemoryLimit !== undefined && maxMmlExceeded === false) { + // @ts-ignore + const effectiveMaxMmlBytes = numeral(effectiveMaxModelMemoryLimit).value(); + if (mmlBytes > effectiveMaxMmlBytes) { + messages.push({ + id: 'mml_greater_than_effective_max_mml', + maxModelMemoryLimit, + mml, + effectiveMaxModelMemoryLimit, + }); + } } } diff --git a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts similarity index 76% rename from x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js rename to x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts index e3ef62e50748..2c3b2dd4dc6a 100644 --- a/x-pack/plugins/ml/server/models/job_validation/__tests__/validate_time_range.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts @@ -5,28 +5,32 @@ */ import _ from 'lodash'; -import expect from '@kbn/expect'; -import { isValidTimeField, validateTimeRange } from '../validate_time_range'; -import mockTimeField from './mock_time_field'; -import mockTimeFieldNested from './mock_time_field_nested'; -import mockTimeRange from './mock_time_range'; +import { APICaller } from 'kibana/server'; + +import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; + +import { isValidTimeField, validateTimeRange } from './validate_time_range'; + +import mockTimeField from './__mocks__/mock_time_field.json'; +import mockTimeFieldNested from './__mocks__/mock_time_field_nested.json'; +import mockTimeRange from './__mocks__/mock_time_range.json'; const mockSearchResponse = { fieldCaps: mockTimeField, search: mockTimeRange, }; -const callWithRequestFactory = resp => { - return path => { +const callWithRequestFactory = (resp: any): APICaller => { + return (path: string) => { return new Promise(resolve => { resolve(resp[path]); - }); + }) as Promise; }; }; function getMinimalValidJob() { - return { + return ({ analysis_config: { bucket_span: '15m', detectors: [], @@ -36,12 +40,15 @@ function getMinimalValidJob() { datafeed_config: { indices: [], }, - }; + } as unknown) as CombinedJob; } describe('ML - isValidTimeField', () => { it('called without job config argument triggers Promise rejection', done => { - isValidTimeField(callWithRequestFactory(mockSearchResponse)).then( + isValidTimeField( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); @@ -50,7 +57,7 @@ describe('ML - isValidTimeField', () => { it('time_field `@timestamp`', done => { isValidTimeField(callWithRequestFactory(mockSearchResponse), getMinimalValidJob()).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `@timestamp`.')) @@ -71,7 +78,7 @@ describe('ML - isValidTimeField', () => { mockJobConfigNestedDate ).then( valid => { - expect(valid).to.be(true); + expect(valid).toBe(true); done(); }, () => done(new Error('isValidTimeField Promise failed for time_field `metadata.timestamp`.')) @@ -81,14 +88,19 @@ describe('ML - isValidTimeField', () => { describe('ML - validateTimeRange', () => { it('called without arguments', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse)).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (undefined as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', done => { - validateTimeRange(callWithRequestFactory(mockSearchResponse), { analysis_config: {} }).then( + validateTimeRange(callWithRequestFactory(mockSearchResponse), ({ + analysis_config: {}, + } as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -96,7 +108,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #3, missing datafeed_config.indices', done => { const job = { analysis_config: {}, datafeed_config: {} }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -104,7 +119,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #4, missing data_description', done => { const job = { analysis_config: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -112,7 +130,10 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #5, missing data_description.time_field', done => { const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; - validateTimeRange(callWithRequestFactory(mockSearchResponse), job).then( + validateTimeRange( + callWithRequestFactory(mockSearchResponse), + (job as unknown) as CombinedJob + ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -128,7 +149,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_field_invalid']); + expect(ids).toStrictEqual(['time_field_invalid']); }); }); @@ -142,7 +163,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -154,7 +175,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -166,7 +187,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_short']); + expect(ids).toStrictEqual(['time_range_short']); }); }); @@ -178,7 +199,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['success_time_range']); + expect(ids).toStrictEqual(['success_time_range']); }); }); @@ -190,7 +211,7 @@ describe('ML - validateTimeRange', () => { duration ).then(messages => { const ids = messages.map(m => m.id); - expect(ids).to.eql(['time_range_before_epoch']); + expect(ids).toStrictEqual(['time_range_before_epoch']); }); }); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index 5f7343876985..4fb09af94dcc 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -37,9 +37,9 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin fields: [timeField], }); - let fieldType = fieldCaps.fields[timeField]?.date?.type; + let fieldType = fieldCaps?.fields[timeField]?.date?.type; if (fieldType === undefined) { - fieldType = fieldCaps.fields[timeField]?.date_nanos?.type; + fieldType = fieldCaps?.fields[timeField]?.date_nanos?.type; } return fieldType === ES_FIELD_TYPES.DATE || fieldType === ES_FIELD_TYPES.DATE_NANOS; } @@ -47,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: APICaller, job: Combin export async function validateTimeRange( callAsCurrentUser: APICaller, job: CombinedJob, - timeRange: TimeRange | undefined + timeRange?: TimeRange ) { const messages: ValidateTimeRangeMessage[] = []; diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts new file mode 100644 index 000000000000..1e50950bc3bc --- /dev/null +++ b/x-pack/plugins/ml/server/shared.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from '../common/types/anomalies'; diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index b90a9aa7d139..0722a80dc2c1 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -140,7 +140,7 @@ export class BulkUploader { async _fetchAndUpload(usageCollection) { const collectorsReady = await usageCollection.areAllCollectorsReady(); const hasUsageCollectors = usageCollection.some(usageCollection.isUsageCollector); - if (!collectorsReady) { + if (!collectorsReady || typeof this.kibanaStatusGetter !== 'function') { this._log.debug('Skipping bulk uploading because not all collectors are ready'); if (hasUsageCollectors) { this._lastFetchUsageTime = null; @@ -151,7 +151,7 @@ export class BulkUploader { const data = await usageCollection.bulkFetch(this._cluster.callAsInternalUser); const payload = this.toBulkUploadFormat(compact(data), usageCollection); - if (payload) { + if (payload && payload.length > 0) { try { this._log.debug(`Uploading bulk stats payload to the local cluster`); const result = await this._onPayload(payload); @@ -244,7 +244,7 @@ export class BulkUploader { */ toBulkUploadFormat(rawData, usageCollection) { if (rawData.length === 0) { - return; + return []; } // convert the raw data to a nested object by taking each payload through diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx index ac2a2997515d..6579d18556cc 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx @@ -44,6 +44,7 @@ export const ShardDetails = ({ index, shard, operations }: Props) => { setShardVisibility(!shardVisibility)} + data-test-subj="openCloseShardDetails" > [{shard.id[0]}][ {shard.id[2]}] diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details_tree_node.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details_tree_node.tsx index 1d8f915d3d47..d89046090a96 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details_tree_node.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details_tree_node.tsx @@ -94,6 +94,7 @@ export const ShardDetailsTreeNode = ({ operation, index, shard }: Props) => { highlight({ indexName: index.name, operation, shard })} > {i18n.translate('xpack.searchProfiler.profileTree.body.viewDetailsLabel', { diff --git a/x-pack/plugins/searchprofiler/public/application/components/searchprofiler_tabs.tsx b/x-pack/plugins/searchprofiler/public/application/components/searchprofiler_tabs.tsx index 19224e7099fd..7e6dad7df552 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/searchprofiler_tabs.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/searchprofiler_tabs.tsx @@ -24,6 +24,7 @@ export const SearchProfilerTabs = ({ activeTab, activateTab, has }: Props) => { return ( activateTab('searches')} @@ -33,6 +34,7 @@ export const SearchProfilerTabs = ({ activeTab, activateTab, has }: Props) => { })} activateTab('aggregations')} diff --git a/x-pack/plugins/searchprofiler/public/application/containers/profile_query_editor.tsx b/x-pack/plugins/searchprofiler/public/application/containers/profile_query_editor.tsx index 5348c55ad521..f6377d2b4f90 100644 --- a/x-pack/plugins/searchprofiler/public/application/containers/profile_query_editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/containers/profile_query_editor.tsx @@ -120,7 +120,12 @@ export const ProfileQueryEditor = memo(() => { - handleProfileClick()}> + handleProfileClick()} + > {i18n.translate('xpack.searchProfiler.formProfileButtonLabel', { defaultMessage: 'Profile', diff --git a/x-pack/plugins/siem/public/components/ml_popover/types.ts b/x-pack/plugins/siem/public/components/ml_popover/types.ts index 58d40c298b32..005f93650a8e 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/types.ts +++ b/x-pack/plugins/siem/public/components/ml_popover/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AuditMessageBase } from '../../../../ml/common/types/audit_message'; +import { AuditMessageBase } from '../../../../ml/public'; import { MlError } from '../ml/types'; export interface Group { diff --git a/x-pack/plugins/siem/public/components/timeline/body/renderers/formatted_field_helpers.tsx b/x-pack/plugins/siem/public/components/timeline/body/renderers/formatted_field_helpers.tsx index b48cc546fe78..7c9accd4cef4 100644 --- a/x-pack/plugins/siem/public/components/timeline/body/renderers/formatted_field_helpers.tsx +++ b/x-pack/plugins/siem/public/components/timeline/body/renderers/formatted_field_helpers.tsx @@ -7,6 +7,7 @@ import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui'; import { isString, isEmpty } from 'lodash/fp'; import React from 'react'; +import styled from 'styled-components'; import { DefaultDraggable } from '../../../draggables'; import { getEmptyTagValue } from '../../../empty_value'; @@ -18,6 +19,10 @@ import endPointSvg from '../../../../utils/logo_endpoint/64_color.svg'; import * as i18n from './translations'; +const EventModuleFlexItem = styled(EuiFlexItem)` + width: 100%; +`; + export const renderRuleName = ({ contextId, eventId, @@ -87,7 +92,7 @@ export const renderEventModule = ({ endpointRefUrl != null && !isEmpty(endpointRefUrl) ? 'flexStart' : 'spaceBetween' } > - + {content} - + {endpointRefUrl != null && canYouAddEndpointLogo(moduleName, endpointRefUrl) && ( ( const popover = useMemo(() => { return ( - ( panelPaddingSize={!alwaysShow ? 's' : 'none'} > {isOpen ? hoverContent : null} - + ); }, [content, onMouseLeave, isOpen, alwaysShow, hoverContent]); diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx index 9c3d1c90e67d..337ca2e3c918 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/jira/flyout.tsx @@ -109,3 +109,6 @@ export const JiraConnectorFlyout = withConnectorFlyout({ configKeys: ['projectKey'], connectorActionTypeId: '.jira', }); + +// eslint-disable-next-line import/no-default-export +export { JiraConnectorFlyout as default }; diff --git a/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx b/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx index ada9608e37c9..049ccb7cf17b 100644 --- a/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/jira/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { lazy } from 'react'; import { ValidationResult, // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -13,7 +14,6 @@ import { connector } from './config'; import { createActionType } from '../utils'; import logo from './logo.svg'; import { JiraActionConnector } from './types'; -import { JiraConnectorFlyout } from './flyout'; import * as i18n from './translations'; interface Errors { @@ -50,5 +50,5 @@ export const getActionType = createActionType({ selectMessage: i18n.JIRA_DESC, actionTypeTitle: connector.name, validateConnector, - actionConnectorFields: JiraConnectorFlyout, + actionConnectorFields: lazy(() => import('./flyout')), }); diff --git a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx index 5d5d08dacf90..2783e988a640 100644 --- a/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx +++ b/x-pack/plugins/siem/public/lib/connectors/servicenow/flyout.tsx @@ -82,3 +82,6 @@ export const ServiceNowConnectorFlyout = withConnectorFlyout import('./flyout')), }); diff --git a/x-pack/plugins/siem/public/lib/connectors/types.ts b/x-pack/plugins/siem/public/lib/connectors/types.ts index ffb013c347e5..3d3692c9806e 100644 --- a/x-pack/plugins/siem/public/lib/connectors/types.ts +++ b/x-pack/plugins/siem/public/lib/connectors/types.ts @@ -8,6 +8,7 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import { ActionType } from '../../../../triggers_actions_ui/public'; +import { IErrorObject } from '../../../../triggers_actions_ui/public/types'; import { ExternalIncidentServiceConfiguration } from '../../../../actions/server/builtin_action_types/case/types'; import { ActionType as ThirdPartySupportedActions, CaseField } from '../../../../case/common/api'; @@ -42,7 +43,7 @@ export interface ActionConnectorValidationErrors { export type Optional = Omit & Partial; export interface ConnectorFlyoutFormProps { - errors: { [key: string]: string[] }; + errors: IErrorObject; action: T; onChangeSecret: (key: string, value: string) => void; onBlurSecret: (key: string) => void; diff --git a/x-pack/plugins/siem/public/lib/connectors/utils.ts b/x-pack/plugins/siem/public/lib/connectors/utils.ts index 169b4758876e..cc1608a05e2c 100644 --- a/x-pack/plugins/siem/public/lib/connectors/utils.ts +++ b/x-pack/plugins/siem/public/lib/connectors/utils.ts @@ -7,7 +7,6 @@ import { ActionTypeModel, ValidationResult, - ActionParamsProps, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../triggers_actions_ui/public/types'; @@ -31,7 +30,7 @@ export const createActionType = ({ validateConnector, validateParams = connectorParamsValidator, actionConnectorFields, - actionParamsFields = ConnectorParamsFields, + actionParamsFields = null, }: Optional) => (): ActionTypeModel => { return { id, @@ -59,15 +58,6 @@ export const createActionType = ({ }; }; -const ConnectorParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, -}) => { - return null; -}; - const connectorParamsValidator = (actionParams: ActionConnectorParams): ValidationResult => { return { errors: {} }; }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.test.ts index 80594ca74a35..30362392898d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.test.ts @@ -7,10 +7,18 @@ import { getSignalsTemplate } from './get_signals_template'; describe('get_signals_template', () => { - test('it should set the lifecycle name and the rollover alias to be the name of the index passed in', () => { + test('it should set the lifecycle "name" and "rollover_alias" to be the name of the index passed in', () => { const template = getSignalsTemplate('test-index'); expect(template.settings).toEqual({ - index: { lifecycle: { name: 'test-index', rollover_alias: 'test-index' } }, + index: { + lifecycle: { + name: 'test-index', + rollover_alias: 'test-index', + }, + }, + mapping: { + total_fields: { limit: 10000 }, + }, }); }); @@ -28,4 +36,9 @@ describe('get_signals_template', () => { const template = getSignalsTemplate('test-index'); expect(typeof template.mappings.properties.signal).toEqual('object'); }); + + test('it should have a "total_fields" section that is at least 10k in size', () => { + const template = getSignalsTemplate('test-index'); + expect(template.settings.mapping.total_fields.limit).toBeGreaterThanOrEqual(10000); + }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.ts index c6580f0bdda4..01d7182e253c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -17,6 +17,11 @@ export const getSignalsTemplate = (index: string) => { rollover_alias: index, }, }, + mapping: { + total_fields: { + limit: 10000, + }, + }, }, index_patterns: [`${index}-*`], mappings: ecsMapping.mappings, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index e7db22822588..91685a68a60a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -122,20 +122,11 @@ describe('import_rules_route', () => { clients.siemClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(400); expect(response.body).toEqual({ - errors: [ - { - error: { - message: - 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', - status_code: 409, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: + 'To create a rule, the index must exist first. Index mockSignalsIndex does not exist', + status_code: 400, }); }); @@ -145,19 +136,10 @@ describe('import_rules_route', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(200); + expect(response.status).toEqual(500); expect(response.body).toEqual({ - errors: [ - { - error: { - message: 'Test error', - status_code: 400, - }, - rule_id: 'rule-1', - }, - ], - success: false, - success_count: 0, + message: 'Test error', + status_code: 500, }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4d86f0bec650..9ba083ae4808 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -75,6 +75,14 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { body: `Invalid file extension ${fileExtension}`, }); } + const signalsIndex = siemClient.getSignalsIndex(); + const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, signalsIndex); + if (!indexExists) { + return siemResponse.error({ + statusCode: 400, + body: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, + }); + } const objectLimit = config.maxRuleImportExportSize; const readStream = createRulesStreamFromNdJson(objectLimit); @@ -94,166 +102,150 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { const batchParseObjects = chunkParseObjects.shift() ?? []; const newImportRuleResponse = await Promise.all( batchParseObjects.reduce>>((accum, parsedRule) => { - const importsWorkerPromise = new Promise( - async (resolve, reject) => { - if (parsedRule instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedRule.message, - }) - ); - return null; - } - const { - anomaly_threshold: anomalyThreshold, - description, - enabled, - false_positives: falsePositives, - from, - immutable, - query, - language, - machine_learning_job_id: machineLearningJobId, - output_index: outputIndex, - saved_id: savedId, - meta, - filters, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - name, - severity, - tags, - threat, - to, - type, - references, - note, - timeline_id: timelineId, - timeline_title: timelineTitle, - version, - exceptions_list, - } = parsedRule; + const importsWorkerPromise = new Promise(async resolve => { + if (parsedRule instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedRule.message, + }) + ); + return null; + } + const { + anomaly_threshold: anomalyThreshold, + description, + enabled, + false_positives: falsePositives, + from, + immutable, + query, + language, + machine_learning_job_id: machineLearningJobId, + output_index: outputIndex, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + threat, + to, + type, + references, + note, + timeline_id: timelineId, + timeline_title: timelineTitle, + version, + exceptions_list, + } = parsedRule; - try { - validateLicenseForRuleType({ - license: context.licensing.license, - ruleType: type, - }); + try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); - const signalsIndex = siemClient.getSignalsIndex(); - const indexExists = await getIndexExists( - clusterClient.callAsCurrentUser, - signalsIndex - ); - if (!indexExists) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `To create a rule, the index must exist first. Index ${signalsIndex} does not exist`, - }) - ); - } - const rule = await readRules({ alertsClient, ruleId }); - if (rule == null) { - await createRules({ - alertsClient, - anomalyThreshold, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - machineLearningJobId, - outputIndex: signalsIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - actions: [], // Actions are not imported nor exported at this time - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null && request.query.overwrite) { - await patchRules({ - alertsClient, - savedObjectsClient, - description, - enabled, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - savedId, - timelineId, - timelineTitle, - meta, - filters, - id: undefined, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - note, - version, - exceptions_list, - anomalyThreshold, - machineLearningJobId, - }); - resolve({ rule_id: ruleId, status_code: 200 }); - } else if (rule != null) { - resolve( - createBulkErrorObject({ - ruleId, - statusCode: 409, - message: `rule_id: "${ruleId}" already exists`, - }) - ); - } - } catch (err) { + const rule = await readRules({ alertsClient, ruleId }); + if (rule == null) { + await createRules({ + alertsClient, + anomalyThreshold, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + machineLearningJobId, + outputIndex: signalsIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + actions: [], // Actions are not imported nor exported at this time + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null && request.query.overwrite) { + await patchRules({ + alertsClient, + savedObjectsClient, + description, + enabled, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + savedId, + timelineId, + timelineTitle, + meta, + filters, + id: undefined, + ruleId, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + note, + version, + exceptions_list, + anomalyThreshold, + machineLearningJobId, + }); + resolve({ rule_id: ruleId, status_code: 200 }); + } else if (rule != null) { resolve( createBulkErrorObject({ ruleId, - statusCode: 400, - message: err.message, + statusCode: 409, + message: `rule_id: "${ruleId}" already exists`, }) ); } + } catch (err) { + resolve( + createBulkErrorObject({ + ruleId, + statusCode: 400, + message: err.message, + }) + ); } - ); + }); return [...accum, importsWorkerPromise]; }, []) ); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts index e50f82bb482a..a7556d975da4 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rule_actions/saved_object_mappings.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from '../../../../../../../src/core/server'; export const ruleActionsSavedObjectType = 'siem-detection-engine-rule-actions'; -export const ruleActionsSavedObjectMappings = { +export const ruleActionsSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { alertThrottle: { type: 'keyword', diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts index 2dcc90240ad4..c01bc2497d67 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from '../../../../../../../src/core/server'; export const ruleStatusSavedObjectType = 'siem-detection-engine-rule-status'; -export const ruleStatusSavedObjectMappings = { +export const ruleStatusSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { alertId: { type: 'keyword', diff --git a/x-pack/plugins/siem/server/lib/machine_learning/index.ts b/x-pack/plugins/siem/server/lib/machine_learning/index.ts index eb09fdde3cce..865a3cf51604 100644 --- a/x-pack/plugins/siem/server/lib/machine_learning/index.ts +++ b/x-pack/plugins/siem/server/lib/machine_learning/index.ts @@ -6,7 +6,7 @@ import { SearchResponse, SearchParams } from 'elasticsearch'; -import { AnomalyRecordDoc as Anomaly } from '../../../../ml/common/types/anomalies'; +import { AnomalyRecordDoc as Anomaly } from '../../../../ml/server'; export { Anomaly }; export type AnomalyResults = SearchResponse; diff --git a/x-pack/plugins/siem/server/lib/note/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/note/saved_object_mappings.ts index 0f079571b868..de0bb3468e52 100644 --- a/x-pack/plugins/siem/server/lib/note/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/note/saved_object_mappings.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from '../../../../../../src/core/server'; export const noteSavedObjectType = 'siem-ui-timeline-note'; -export const noteSavedObjectMappings = { +export const noteSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { timelineId: { type: 'keyword', diff --git a/x-pack/plugins/siem/server/lib/pinned_event/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/pinned_event/saved_object_mappings.ts index 1a4cd3fce575..d352764930d7 100644 --- a/x-pack/plugins/siem/server/lib/pinned_event/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/pinned_event/saved_object_mappings.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from '../../../../../../src/core/server'; export const pinnedEventSavedObjectType = 'siem-ui-timeline-pinned-event'; -export const pinnedEventSavedObjectMappings = { +export const pinnedEventSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { timelineId: { type: 'keyword', diff --git a/x-pack/plugins/siem/server/lib/timeline/saved_object_mappings.ts b/x-pack/plugins/siem/server/lib/timeline/saved_object_mappings.ts index 1cab24d0879f..4d9ae19bfd6a 100644 --- a/x-pack/plugins/siem/server/lib/timeline/saved_object_mappings.ts +++ b/x-pack/plugins/siem/server/lib/timeline/saved_object_mappings.ts @@ -8,7 +8,7 @@ import { SavedObjectsType } from '../../../../../../src/core/server'; export const timelineSavedObjectType = 'siem-ui-timeline'; -export const timelineSavedObjectMappings = { +export const timelineSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { columns: { properties: { diff --git a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts index 9d8106a1366d..e115e086f45b 100644 --- a/x-pack/plugins/transform/public/__mocks__/shared_imports.ts +++ b/x-pack/plugins/transform/public/__mocks__/shared_imports.ts @@ -14,8 +14,8 @@ export const useRequest = jest.fn(() => ({ })); // just passing through the reimports -export { getErrorMessage } from '../../../ml/common/util/errors'; export { + getErrorMessage, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, @@ -27,5 +27,5 @@ export { SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, -} from '../../../ml/public/application/components/data_grid'; -export { INDEX_STATUS } from '../../../ml/public/application/data_frame_analytics/common'; + INDEX_STATUS, +} from '../../../ml/public'; diff --git a/x-pack/plugins/transform/public/app/common/aggregations.ts b/x-pack/plugins/transform/public/app/common/aggregations.ts index 038d68ff37d8..397a58006f1d 100644 --- a/x-pack/plugins/transform/public/app/common/aggregations.ts +++ b/x-pack/plugins/transform/public/app/common/aggregations.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { composeValidators, patternValidator } from '../../../../ml/common/util/validators'; +import { composeValidators, patternValidator } from '../../../../ml/public'; export type AggName = string; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx index a794b7e7c214..d3dae0a8c8b6 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/edit_transform_flyout/edit_transform_flyout.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; +import React, { useState, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty, + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -18,11 +19,10 @@ import { EuiFlyoutFooter, EuiFlyoutHeader, EuiOverlayMask, + EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; - import { getErrorMessage } from '../../../../../shared_imports'; import { @@ -30,8 +30,7 @@ import { TransformPivotConfig, REFRESH_TRANSFORM_LIST_STATE, } from '../../../../common'; -import { ToastNotificationText } from '../../../../components'; -import { useAppDependencies, useToastNotifications } from '../../../../app_dependencies'; +import { useToastNotifications } from '../../../../app_dependencies'; import { useApi } from '../../../../hooks/use_api'; @@ -48,13 +47,14 @@ interface EditTransformFlyoutProps { } export const EditTransformFlyout: FC = ({ closeFlyout, config }) => { - const { overlays } = useAppDependencies(); const api = useApi(); const toastNotifications = useToastNotifications(); const [state, dispatch] = useEditTransformFlyout(config); + const [errorMessage, setErrorMessage] = useState(undefined); async function submitFormHandler() { + setErrorMessage(undefined); const requestConfig = applyFormFieldsToTransformConfig(config, state.formFields); const transformId = config.id; @@ -69,12 +69,7 @@ export const EditTransformFlyout: FC = ({ closeFlyout, closeFlyout(); refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.REFRESH); } catch (e) { - toastNotifications.addDanger({ - title: i18n.translate('xpack.transform.transformList.editTransformGenericErrorMessage', { - defaultMessage: 'An error occurred calling the API endpoint to update transforms.', - }), - text: toMountPoint(), - }); + setErrorMessage(getErrorMessage(e)); } } @@ -97,6 +92,24 @@ export const EditTransformFlyout: FC = ({ closeFlyout, }> + {errorMessage !== undefined && ( + <> + + +

      {errorMessage}

      +
      + + )}
      diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index bcd8e53e3d19..3737377de2d5 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -16,9 +16,8 @@ export { useRequest, } from '../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; -export { getErrorMessage } from '../../ml/common/util/errors'; - export { + getErrorMessage, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, multiColumnSortFactory, @@ -30,5 +29,5 @@ export { SearchResponse7, UseDataGridReturnType, UseIndexDataReturnType, -} from '../../ml/public/application/components/data_grid'; -export { INDEX_STATUS } from '../../ml/public/application/data_frame_analytics/common'; + INDEX_STATUS, +} from '../../ml/public'; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1adc77267c44..956dcb08e5fc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4209,6 +4209,7 @@ "xpack.apm.errorRateAlertTrigger.isAbove": "の下限は", "xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel": "エラーメッセージと原因", "xpack.apm.errorsTable.groupIdColumnLabel": "グループ ID", + "xpack.apm.errorsTable.groupIdColumnDescription": "スタックトレースのハッシュ。動的パラメーターによりエラーメッセージが異なる場合でも、同様のエラーをグループ化します。", "xpack.apm.errorsTable.latestOccurrenceColumnLabel": "最近のオカレンス", "xpack.apm.errorsTable.noErrorsLabel": "エラーが見つかりませんでした", "xpack.apm.errorsTable.occurrencesColumnLabel": "オカレンス", @@ -4314,7 +4315,6 @@ "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "現在 {serviceName} ({transactionType}) の実行中のジョブがあります。", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "既存のジョブを表示", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "ジョブが既に存在します", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription": "ここでは、{serviceName} 数列内の APM トランザクションの期間の異常スコアを計算する機械学習ジョブを作成できます。有効にすると、{transactionDurationGraphText} が予測バウンドを表示し、異常スコアが >=75 の場合グラフに注釈が追加されます。", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "トランザクション時間のグラフ", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "ジョブを作成", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "異常検知を有効にする", @@ -6589,7 +6589,6 @@ "xpack.graph.sidebar.selectionsTitle": "選択項目", "xpack.graph.sidebar.styleVerticesTitle": "スタイルが選択された頂点", "xpack.graph.sidebar.topMenu.addLinksButtonTooltip": "既存の用語の間にリンクを追加します", - "xpack.graph.sidebar.topMenu.blacklistButtonTooltip": "選択項目がワークスペースに戻らないようブラックリストに追加します", "xpack.graph.sidebar.topMenu.customStyleButtonTooltip": "選択された頂点のカスタムスタイル", "xpack.graph.sidebar.topMenu.drillDownButtonTooltip": "ドリルダウン", "xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip": "選択項目を拡張", @@ -10054,7 +10053,6 @@ "xpack.ml.models.jobValidation.messages.jobIdInvalidMessage": "ジョブ ID が無効です。アルファベットの小文字 (a-z と 0-9)、ハイフンまたはアンダーラインが使用でき、最初と最後を英数字にする必要があります。", "xpack.ml.models.jobValidation.messages.jobIdValidHeading": "ジョブ ID のフォーマットは有効です。", "xpack.ml.models.jobValidation.messages.jobIdValidMessage": "アルファベットの小文字 (a-z と 0-9)、ハイフンまたはアンダーライン、最初と最後を英数字にし、{maxLength, plural, one {# 文字} other {# 文字}}以内にする必要があります。", - "xpack.ml.models.jobValidation.messages.mmlGreaterThanMaxMmlMessage": "モデルメモリー制限が、このクラスターに構成された最大モデルメモリー制限を超えています。", "xpack.ml.models.jobValidation.messages.mmlValueInvalidMessage": "{mml} はモデルメモリー制限の有効な値ではありません。この値は最低 1MB で、バイト (例: 10MB) で指定する必要があります。", "xpack.ml.models.jobValidation.messages.skippedExtendedTestsMessage": "ジョブの構成の基本要件が満たされていないため、他のチェックをスキップしました。", "xpack.ml.models.jobValidation.messages.successBucketSpanHeading": "バケットスパン", @@ -12201,7 +12199,6 @@ "xpack.reporting.publicNotifier.successfullyCreatedReportNotificationTitle": "{reportObjectType}「{reportObjectTitle}」のレポートが作成されました", "xpack.reporting.registerFeature.reportingDescription": "ディスカバリ、可視化、ダッシュボードから生成されたレポートを管理します。", "xpack.reporting.registerFeature.reportingTitle": "レポート", - "xpack.reporting.screencapture.asyncTook": "{description} にかかった時間は {took}ms でした", "xpack.reporting.screencapture.couldntFinishRendering": "{count} 件のビジュアライゼーションのレンダリングが完了するのを待つ間にエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.couldntLoadKibana": "Kibana URL を開こうとするときにエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.injectCss": "Kibana CSS をレポート用に更新しようとしたときにエラーが発生しました。{error}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a57b517123e7..cc42647f356b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4209,6 +4209,7 @@ "xpack.apm.errorRateAlertTrigger.errors": "错误", "xpack.apm.errorRateAlertTrigger.isAbove": "高于", "xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel": "错误消息和原因", + "xpack.apm.errorsTable.groupIdColumnDescription": "堆栈跟踪的哈希值。即使由于动态参数而导致错误消息不同,也将相似的错误归为一组。", "xpack.apm.errorsTable.groupIdColumnLabel": "组 ID", "xpack.apm.errorsTable.latestOccurrenceColumnLabel": "最新一次发生", "xpack.apm.errorsTable.noErrorsLabel": "未找到任何错误", @@ -4315,7 +4316,6 @@ "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription": "当前有 {serviceName}({transactionType})的作业正在运行。", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText": "查看现有作业", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsTitle": "作业已存在", - "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription": "在这里可以创建 Machine Learning 作业以基于 {serviceName} 服务内 APM 事务的持续时间计算异常分数。启用后,一旦异常分数 >=75,{transactionDurationGraphText}将显示预期边界并标注图表。", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createMLJobDescription.transactionDurationGraphText": "事务持续时间图表", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.createNewJobButtonLabel": "创建作业", "xpack.apm.serviceDetails.enableAnomalyDetectionPanel.enableAnomalyDetectionTitle": "启用异常检测", @@ -6594,7 +6594,6 @@ "xpack.graph.sidebar.selectionsTitle": "选择的内容", "xpack.graph.sidebar.styleVerticesTitle": "样式选择的顶点", "xpack.graph.sidebar.topMenu.addLinksButtonTooltip": "在现有字词之间添加链接", - "xpack.graph.sidebar.topMenu.blacklistButtonTooltip": "返回工作空间时选择的黑名单", "xpack.graph.sidebar.topMenu.customStyleButtonTooltip": "定制样式选择的顶点", "xpack.graph.sidebar.topMenu.drillDownButtonTooltip": "向下钻取", "xpack.graph.sidebar.topMenu.expandSelectionButtonTooltip": "展开选择内容", @@ -10060,7 +10059,6 @@ "xpack.ml.models.jobValidation.messages.jobIdInvalidMessage": "作业 ID 无效.其可以包含小写字母数字(a-z 和 0-9)字符、连字符或下划线,且必须以字母数字字符开头和结尾。", "xpack.ml.models.jobValidation.messages.jobIdValidHeading": "作业 ID 格式有效", "xpack.ml.models.jobValidation.messages.jobIdValidMessage": "小写字母数字(a-z 和 0-9)字符、连字符或下划线,以字母数字字符开头和结尾,且长度不超过 {maxLength, plural, one {# 个字符} other {# 个字符}}。", - "xpack.ml.models.jobValidation.messages.mmlGreaterThanMaxMmlMessage": "模型内存限制大于为此集群配置的最大模型内存限制。", "xpack.ml.models.jobValidation.messages.mmlValueInvalidMessage": "{mml} 不是有效的模型内存限制值。该值需要至少 1MB,且应以字节为单位(例如 10MB)指定。", "xpack.ml.models.jobValidation.messages.skippedExtendedTestsMessage": "已跳过其他检查,因为未满足作业配置的基本要求。", "xpack.ml.models.jobValidation.messages.successBucketSpanHeading": "存储桶跨度", @@ -12208,7 +12206,6 @@ "xpack.reporting.publicNotifier.successfullyCreatedReportNotificationTitle": "已为 {reportObjectType}“{reportObjectTitle}”创建报告", "xpack.reporting.registerFeature.reportingDescription": "管理您从 Discover、Visualize 和 Dashboard 生成的报告。", "xpack.reporting.registerFeature.reportingTitle": "报告", - "xpack.reporting.screencapture.asyncTook": "{description} 花费了 {took}ms", "xpack.reporting.screencapture.couldntFinishRendering": "尝试等候 {count} 个可视化完成渲染时发生错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.couldntLoadKibana": "尝试打开 Kibana URL 时发生了错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.injectCss": "尝试为 Reporting 更新 Kibana CSS 时发生错误。{error}", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index ece1791c66e1..c5f02863ba8a 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -985,8 +985,8 @@ Each action type should be defined as an `ActionTypeModel` object with the follo |selectMessage|Short description of action type responsibility, that will be displayed on the select card in UI.| |validateConnector|Validation function for action connector.| |validateParams|Validation function for action params.| -|actionConnectorFields|React functional component for building UI of current action type connector.| -|actionParamsFields|React functional component for building UI of current action type params. Displayed as a part of Create Alert flyout.| +|actionConnectorFields|A lazy loaded React component for building UI of current action type connector.| +|actionParamsFields|A lazy loaded React component for building UI of current action type params. Displayed as a part of Create Alert flyout.| ## Register action type model @@ -1082,8 +1082,8 @@ export function getActionType(): ActionTypeModel { } return validationResult; }, - actionConnectorFields: ExampleConnectorFields, - actionParamsFields: ExampleParamsFields, + actionConnectorFields: lazy(() => import('./example_connector_fields')), + actionParamsFields: lazy(() => import('./example_params_fields')), }; } ``` @@ -1130,6 +1130,9 @@ const ExampleConnectorFields: React.FunctionComponent ); }; + +// Export as default in order to support lazy loading +export {ExampleConnectorFields as default}; ``` 3. Define action type params fields using the property of `ActionTypeModel` `actionParamsFields`: @@ -1175,6 +1178,9 @@ const ExampleParamsFields: React.FunctionComponent ); }; + +// Export as default in order to support lazy loading +export {ExampleParamsFields as default}; ``` 4. Extend registration code with the new action type register in the file `x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts` diff --git a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx index 0593940a0d10..63860e062c8d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/app.tsx @@ -3,8 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { Switch, Route, Redirect, HashRouter } from 'react-router-dom'; +import React, { lazy, Suspense } from 'react'; +import { Switch, Route, Redirect, HashRouter, RouteComponentProps } from 'react-router-dom'; import { ChromeStart, DocLinksStart, @@ -15,17 +15,21 @@ import { ChromeBreadcrumb, CoreStart, } from 'kibana/public'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { BASE_PATH, Section, routeToAlertDetails } from './constants'; -import { TriggersActionsUIHome } from './home'; import { AppContextProvider, useAppDependencies } from './app_context'; import { hasShowAlertsCapability } from './lib/capabilities'; import { ActionTypeModel, AlertTypeModel } from '../types'; import { TypeRegistry } from './type_registry'; -import { AlertDetailsRouteWithApi as AlertDetailsRoute } from './sections/alert_details/components/alert_details_route'; import { ChartsPluginStart } from '../../../../../src/plugins/charts/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { PluginStartContract as AlertingStart } from '../../../alerting/public'; +const TriggersActionsUIHome = lazy(async () => import('./home')); +const AlertDetailsRoute = lazy(() => + import('./sections/alert_details/components/alert_details_route') +); + export interface AppDeps { dataPlugin: DataPublicPluginStart; charts: ChartsPluginStart; @@ -62,9 +66,32 @@ export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) = const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; return ( - - {canShowAlerts && } + + {canShowAlerts && ( + + )} ); }; + +function suspendedRouteComponent( + RouteComponent: React.ComponentType> +) { + return (props: RouteComponentProps) => ( + + + + + + } + > + + + ); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx deleted file mode 100644 index dff697297f3e..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ /dev/null @@ -1,609 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { Fragment, useState, useEffect } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFieldText, - EuiFlexItem, - EuiFlexGroup, - EuiFieldNumber, - EuiFieldPassword, - EuiComboBox, - EuiTextArea, - EuiButtonEmpty, - EuiSwitch, - EuiFormRow, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { EmailActionParams, EmailActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - const mailformat = /^[^@\s]+@[^@\s]+$/; - return { - id: '.email', - iconClass: 'email', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', - { - defaultMessage: 'Send email from your server.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle', - { - defaultMessage: 'Send to email', - } - ), - validateConnector: (action: EmailActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - from: new Array(), - port: new Array(), - host: new Array(), - user: new Array(), - password: new Array(), - }; - validationResult.errors = errors; - if (!action.config.from) { - errors.from.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', - { - defaultMessage: 'Sender is required.', - } - ) - ); - } - if (action.config.from && !action.config.from.trim().match(mailformat)) { - errors.from.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', - { - defaultMessage: 'Sender is not a valid email address.', - } - ) - ); - } - if (!action.config.port) { - errors.port.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', - { - defaultMessage: 'Port is required.', - } - ) - ); - } - if (!action.config.host) { - errors.host.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', - { - defaultMessage: 'Host is required.', - } - ) - ); - } - if (action.secrets.user && !action.secrets.password) { - errors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', - { - defaultMessage: 'Password is required when username is used.', - } - ) - ); - } - if (!action.secrets.user && action.secrets.password) { - errors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', - { - defaultMessage: 'Username is required when password is used.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: EmailActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - to: new Array(), - cc: new Array(), - bcc: new Array(), - message: new Array(), - subject: new Array(), - }; - validationResult.errors = errors; - if ( - (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && - (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && - (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) - ) { - const errorText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', - { - defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', - } - ); - errors.to.push(errorText); - errors.cc.push(errorText); - errors.bcc.push(errorText); - } - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - if (!actionParams.subject?.length) { - errors.subject.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', - { - defaultMessage: 'Subject is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: EmailActionConnectorFields, - actionParamsFields: EmailParamsFields, - }; -} - -const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { - const { from, host, port, secure } = action.config; - const { user, password } = action.secrets; - - return ( - - - - 0 && from !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', - { - defaultMessage: 'Sender', - } - )} - > - 0 && from !== undefined} - name="from" - value={from || ''} - data-test-subj="emailFromInput" - onChange={e => { - editActionConfig('from', e.target.value); - }} - onBlur={() => { - if (!from) { - editActionConfig('from', ''); - } - }} - /> - - - - - - 0 && host !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', - { - defaultMessage: 'Host', - } - )} - > - 0 && host !== undefined} - name="host" - value={host || ''} - data-test-subj="emailHostInput" - onChange={e => { - editActionConfig('host', e.target.value); - }} - onBlur={() => { - if (!host) { - editActionConfig('host', ''); - } - }} - /> - - - - - - 0 && port !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', - { - defaultMessage: 'Port', - } - )} - > - 0 && port !== undefined} - fullWidth - name="port" - value={port || ''} - data-test-subj="emailPortInput" - onChange={e => { - editActionConfig('port', parseInt(e.target.value, 10)); - }} - onBlur={() => { - if (!port) { - editActionConfig('port', 0); - } - }} - /> - - - - - - { - editActionConfig('secure', e.target.checked); - }} - /> - - - - - - - - - 0} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', - { - defaultMessage: 'Username', - } - )} - > - 0} - name="user" - value={user || ''} - data-test-subj="emailUserInput" - onChange={e => { - editActionSecrets('user', nullableString(e.target.value)); - }} - /> - - - - 0} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', - { - defaultMessage: 'Password', - } - )} - > - 0} - name="password" - value={password || ''} - data-test-subj="emailPasswordInput" - onChange={e => { - editActionSecrets('password', nullableString(e.target.value)); - }} - /> - - - - - ); -}; - -const EmailParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, - messageVariables, - defaultMessage, -}) => { - const { to, cc, bcc, subject, message } = actionParams; - const toOptions = to ? to.map((label: string) => ({ label })) : []; - const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; - const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; - const [addCC, setAddCC] = useState(false); - const [addBCC, setAddBCC] = useState(false); - - useEffect(() => { - if (!message && defaultMessage && defaultMessage.length > 0) { - editAction('message', defaultMessage, index); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction( - paramsProperty, - ((actionParams as any)[paramsProperty] ?? '').concat(` {{${variable}}}`), - index - ); - }; - - return ( - - 0 && to !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', - { - defaultMessage: 'To', - } - )} - labelAppend={ - - - {!addCC ? ( - setAddCC(true)}> - - - ) : null} - {!addBCC ? ( - setAddBCC(true)}> - - - ) : null} - - - } - > - 0 && to !== undefined} - fullWidth - data-test-subj="toEmailAddressInput" - selectedOptions={toOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...toOptions, { label: searchValue }]; - editAction( - 'to', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'to', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!to) { - editAction('to', [], index); - } - }} - /> - - {addCC ? ( - 0 && cc !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', - { - defaultMessage: 'Cc', - } - )} - > - 0 && cc !== undefined} - fullWidth - data-test-subj="ccEmailAddressInput" - selectedOptions={ccOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...ccOptions, { label: searchValue }]; - editAction( - 'cc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'cc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!cc) { - editAction('cc', [], index); - } - }} - /> - - ) : null} - {addBCC ? ( - 0 && bcc !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', - { - defaultMessage: 'Bcc', - } - )} - > - 0 && bcc !== undefined} - fullWidth - data-test-subj="bccEmailAddressInput" - selectedOptions={bccOptions} - onCreateOption={(searchValue: string) => { - const newOptions = [...bccOptions, { label: searchValue }]; - editAction( - 'bcc', - newOptions.map(newOption => newOption.label), - index - ); - }} - onChange={(selectedOptions: Array<{ label: string }>) => { - editAction( - 'bcc', - selectedOptions.map(selectedOption => selectedOption.label), - index - ); - }} - onBlur={() => { - if (!bcc) { - editAction('bcc', [], index); - } - }} - /> - - ) : null} - 0 && subject !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', - { - defaultMessage: 'Subject', - } - )} - labelAppend={ - - onSelectMessageVariable('subject', variable) - } - paramsProperty="subject" - /> - } - > - 0 && subject !== undefined} - name="subject" - data-test-subj="emailSubjectInput" - value={subject || ''} - onChange={e => { - editAction('subject', e.target.value, index); - }} - onBlur={() => { - if (!subject) { - editAction('subject', '', index); - } - }} - /> - - 0 && message !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel', - { - defaultMessage: 'Message', - } - )} - labelAppend={ - - onSelectMessageVariable('message', variable) - } - paramsProperty="message" - /> - } - > - 0 && message !== undefined} - value={message || ''} - name="message" - data-test-subj="emailMessageInput" - onChange={e => { - editAction('message', e.target.value, index); - }} - onBlur={() => { - if (!message) { - editAction('message', '', index); - } - }} - /> - - - ); -}; - -// if the string == null or is empty, return null, else return string -function nullableString(str: string | null | undefined) { - if (str == null || str.trim() === '') return null; - return str; -} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx similarity index 62% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx index af9e34071fd0..e823e848f52c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.test.tsx @@ -3,12 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { EmailActionParams, EmailActionConnector } from './types'; +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '../index'; +import { ActionTypeModel } from '../../../../types'; +import { EmailActionConnector } from '../types'; const ACTION_TYPE_ID = '.email'; let actionTypeModel: ActionTypeModel; @@ -206,80 +204,3 @@ describe('action params validation', () => { }); }); }); - -describe('EmailActionConnectorFields renders', () => { - test('all connector fields is rendered', () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: { - from: 'test@test.com', - }, - } as EmailActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> - ); - expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="emailFromInput"]') - .first() - .prop('value') - ).toBe('test@test.com'); - expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('EmailParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - cc: [], - bcc: [], - to: ['test@test.com'], - subject: 'test', - message: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="toEmailAddressInput"]') - .first() - .prop('selectedOptions') - ).toStrictEqual([{ label: 'test@test.com' }]); - expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx new file mode 100644 index 000000000000..abb102c04b05 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { EmailActionParams, EmailActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + const mailformat = /^[^@\s]+@[^@\s]+$/; + return { + id: '.email', + iconClass: 'email', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', + { + defaultMessage: 'Send email from your server.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.actionTypeTitle', + { + defaultMessage: 'Send to email', + } + ), + validateConnector: (action: EmailActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + from: new Array(), + port: new Array(), + host: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.from) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } + ) + ); + } + if (action.config.from && !action.config.from.trim().match(mailformat)) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } + ) + ); + } + if (!action.config.port) { + errors.port.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } + ) + ); + } + if (!action.config.host) { + errors.host.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } + ) + ); + } + if (action.secrets.user && !action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } + ) + ); + } + if (!action.secrets.user && action.secrets.password) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: EmailActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + to: new Array(), + cc: new Array(), + bcc: new Array(), + message: new Array(), + subject: new Array(), + }; + validationResult.errors = errors; + if ( + (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && + (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && + (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) + ) { + const errorText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', + } + ); + errors.to.push(errorText); + errors.cc.push(errorText); + errors.bcc.push(errorText); + } + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + if (!actionParams.subject?.length) { + errors.subject.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./email_connector')), + actionParamsFields: lazy(() => import('./email_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx new file mode 100644 index 000000000000..67514e815bc4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EmailActionConnector } from '../types'; +import EmailActionConnectorFields from './email_connector'; +import { DocLinksStart } from 'kibana/public'; + +describe('EmailActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as EmailActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + /> + ); + expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="emailFromInput"]') + .first() + .prop('value') + ).toBe('test@test.com'); + expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx new file mode 100644 index 000000000000..4ef4c8a4d861 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiFieldPassword, + EuiSwitch, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { EmailActionConnector } from '../types'; + +export const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { + const { from, host, port, secure } = action.config; + const { user, password } = action.secrets; + + return ( + + + + 0 && from !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', + { + defaultMessage: 'Sender', + } + )} + > + 0 && from !== undefined} + name="from" + value={from || ''} + data-test-subj="emailFromInput" + onChange={e => { + editActionConfig('from', e.target.value); + }} + onBlur={() => { + if (!from) { + editActionConfig('from', ''); + } + }} + /> + + + + + + 0 && host !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', + { + defaultMessage: 'Host', + } + )} + > + 0 && host !== undefined} + name="host" + value={host || ''} + data-test-subj="emailHostInput" + onChange={e => { + editActionConfig('host', e.target.value); + }} + onBlur={() => { + if (!host) { + editActionConfig('host', ''); + } + }} + /> + + + + + + 0 && port !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', + { + defaultMessage: 'Port', + } + )} + > + 0 && port !== undefined} + fullWidth + name="port" + value={port || ''} + data-test-subj="emailPortInput" + onChange={e => { + editActionConfig('port', parseInt(e.target.value, 10)); + }} + onBlur={() => { + if (!port) { + editActionConfig('port', 0); + } + }} + /> + + + + + + { + editActionConfig('secure', e.target.checked); + }} + /> + + + + + + + + + 0} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0} + name="user" + value={user || ''} + data-test-subj="emailUserInput" + onChange={e => { + editActionSecrets('user', nullableString(e.target.value)); + }} + /> + + + + 0} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0} + name="password" + value={password || ''} + data-test-subj="emailPasswordInput" + onChange={e => { + editActionSecrets('password', nullableString(e.target.value)); + }} + /> + + + + + ); +}; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} + +// eslint-disable-next-line import/no-default-export +export { EmailActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx new file mode 100644 index 000000000000..a2b5ccf988af --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import EmailParamsFields from './email_params'; + +describe('EmailParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + cc: [], + bcc: [], + to: ['test@test.com'], + subject: 'test', + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="toEmailAddressInput"]') + .first() + .prop('selectedOptions') + ).toStrictEqual([{ label: 'test@test.com' }]); + expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx new file mode 100644 index 000000000000..13e791f1069e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_params.tsx @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFieldText, EuiComboBox, EuiTextArea, EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { EmailActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +export const EmailParamsFields = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, +}: ActionParamsProps) => { + const { to, cc, bcc, subject, message } = actionParams; + const toOptions = to ? to.map((label: string) => ({ label })) : []; + const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; + const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; + const [addCC, setAddCC] = useState(false); + const [addBCC, setAddBCC] = useState(false); + + useEffect(() => { + if (!message && defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction( + paramsProperty, + ((actionParams as any)[paramsProperty] ?? '').concat(` {{${variable}}}`), + index + ); + }; + + return ( + + 0 && to !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientTextFieldLabel', + { + defaultMessage: 'To', + } + )} + labelAppend={ + + + {!addCC ? ( + setAddCC(true)}> + + + ) : null} + {!addBCC ? ( + setAddBCC(true)}> + + + ) : null} + + + } + > + 0 && to !== undefined} + fullWidth + data-test-subj="toEmailAddressInput" + selectedOptions={toOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...toOptions, { label: searchValue }]; + editAction( + 'to', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'to', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!to) { + editAction('to', [], index); + } + }} + /> + + {addCC ? ( + 0 && cc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientCopyTextFieldLabel', + { + defaultMessage: 'Cc', + } + )} + > + 0 && cc !== undefined} + fullWidth + data-test-subj="ccEmailAddressInput" + selectedOptions={ccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...ccOptions, { label: searchValue }]; + editAction( + 'cc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'cc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!cc) { + editAction('cc', [], index); + } + }} + /> + + ) : null} + {addBCC ? ( + 0 && bcc !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.recipientBccTextFieldLabel', + { + defaultMessage: 'Bcc', + } + )} + > + 0 && bcc !== undefined} + fullWidth + data-test-subj="bccEmailAddressInput" + selectedOptions={bccOptions} + onCreateOption={(searchValue: string) => { + const newOptions = [...bccOptions, { label: searchValue }]; + editAction( + 'bcc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'bcc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!bcc) { + editAction('bcc', [], index); + } + }} + /> + + ) : null} + 0 && subject !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.subjectTextFieldLabel', + { + defaultMessage: 'Subject', + } + )} + labelAppend={ + + onSelectMessageVariable('subject', variable) + } + paramsProperty="subject" + /> + } + > + 0 && subject !== undefined} + name="subject" + data-test-subj="emailSubjectInput" + value={subject || ''} + onChange={e => { + editAction('subject', e.target.value, index); + }} + onBlur={() => { + if (!subject) { + editAction('subject', '', index); + } + }} + /> + + 0 && message !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.messageTextAreaFieldLabel', + { + defaultMessage: 'Message', + } + )} + labelAppend={ + + onSelectMessageVariable('message', variable) + } + paramsProperty="message" + /> + } + > + 0 && message !== undefined} + value={message || ''} + name="message" + data-test-subj="emailMessageInput" + onChange={e => { + editAction('message', e.target.value, index); + }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { EmailParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/index.ts new file mode 100644 index 000000000000..e0dd24a44aa8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getEmailActionType } from './email'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx deleted file mode 100644 index 04dc7b484ed4..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.test.tsx +++ /dev/null @@ -1,240 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { IndexActionParams, EsIndexActionConnector } from './types'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; -jest.mock('../../../common/index_controls', () => ({ - firstFieldOption: jest.fn(), - getFields: jest.fn(), - getIndexOptions: jest.fn(), - getIndexPatterns: jest.fn(), -})); - -const ACTION_TYPE_ID = '.index'; -let actionTypeModel: ActionTypeModel; -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - const mocks = coreMock.createSetup(); - const [ - { - application: { capabilities }, - }, - ] = await mocks.getStartServices(); - deps = { - toastNotifications: mocks.notifications.toasts, - http: mocks.http, - capabilities: { - ...capabilities, - actions: { - delete: true, - save: true, - show: true, - }, - }, - actionTypeRegistry: actionTypeRegistry as any, - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type .index is registered', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('indexOpen'); - }); -}); - -describe('index connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test_es_index', - refresh: false, - executionTimeField: '1', - }, - } as EsIndexActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - index: [], - }, - }); - }); -}); - -describe('index connector validation with minimal config', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test_es_index', - }, - } as EsIndexActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - index: [], - }, - }); - }); -}); - -describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - documents: ['test'], - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: {}, - }); - - const emptyActionParams = {}; - - expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ - errors: {}, - }); - }); -}); - -describe('IndexActionConnectorFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - - const { getIndexPatterns } = jest.requireMock('../../../common/index_controls'); - getIndexPatterns.mockResolvedValueOnce([ - { - id: 'indexPattern1', - attributes: { - title: 'indexPattern1', - }, - }, - { - id: 'indexPattern2', - attributes: { - title: 'indexPattern2', - }, - }, - ]); - const { getFields } = jest.requireMock('../../../common/index_controls'); - getFields.mockResolvedValueOnce([ - { - type: 'date', - name: 'test1', - }, - { - type: 'text', - name: 'test2', - }, - ]); - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.index', - name: 'es_index', - config: { - index: 'test', - refresh: false, - executionTimeField: 'test1', - }, - } as EsIndexActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - http={deps!.http} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); - - const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); - expect(indexSearchBoxValue.first().props().value).toEqual(''); - - const indexComboBox = wrapper.find('#indexConnectorSelectSearchBox'); - indexComboBox.first().simulate('click'); - const event = { target: { value: 'indexPattern1' } }; - indexComboBox - .find('input') - .first() - .simulate('change', event); - - const indexSearchBoxValueBeforeEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueBeforeEnterData.first().props().value).toEqual('indexPattern1'); - - const indexComboBoxClear = wrapper.find('[data-test-subj="comboBoxClearButton"]'); - indexComboBoxClear.first().simulate('click'); - - const indexSearchBoxValueAfterEnterData = wrapper.find( - '[data-test-subj="comboBoxSearchInput"]' - ); - expect(indexSearchBoxValueAfterEnterData.first().props().value).toEqual('indexPattern1'); - }); -}); - -describe('IndexParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - documents: [{ test: 123 }], - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect( - wrapper - .find('[data-test-subj="actionIndexDoc"]') - .first() - .prop('value') - ).toBe(`{ - "test": 123 -}`); - expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx new file mode 100644 index 000000000000..417a9e09086a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '../index'; +import { ActionTypeModel } from '../../../../types'; +import { EsIndexActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.index'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type .index is registered', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('indexOpen'); + }); +}); + +describe('index connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + refresh: false, + executionTimeField: '1', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + +describe('index connector validation with minimal config', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + documents: ['test'], + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + + const emptyActionParams = {}; + + expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ + errors: {}, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx new file mode 100644 index 000000000000..3ee663a5fc8a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { EsIndexActionConnector, IndexActionParams } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.index', + iconClass: 'indexOpen', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', + { + defaultMessage: 'Index data into Elasticsearch.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle', + { + defaultMessage: 'Index data', + } + ), + validateConnector: (action: EsIndexActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + index: new Array(), + }; + validationResult.errors = errors; + if (!action.config.index) { + errors.index.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', + { + defaultMessage: 'Index is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./es_index_connector')), + actionParamsFields: lazy(() => import('./es_index_params')), + validateParams: (): ValidationResult => { + return { errors: {} }; + }, + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx new file mode 100644 index 000000000000..b0f21afeaa96 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { EsIndexActionConnector } from '../types'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import IndexActionConnectorFields from './es_index_connector'; +import { TypeRegistry } from '../../../type_registry'; +import { DocLinksStart } from 'kibana/public'; + +jest.mock('../../../../common/index_controls', () => ({ + firstFieldOption: jest.fn(), + getFields: jest.fn(), + getIndexOptions: jest.fn(), + getIndexPatterns: jest.fn(), +})); + +describe('IndexActionConnectorFields renders', () => { + test('all connector fields is rendered', async () => { + const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + const deps = { + toastNotifications: mocks.notifications.toasts, + http: mocks.http, + capabilities: { + ...capabilities, + actions: { + delete: true, + save: true, + show: true, + }, + }, + actionTypeRegistry: {} as TypeRegistry, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + + const { getIndexPatterns } = jest.requireMock('../../../../common/index_controls'); + getIndexPatterns.mockResolvedValueOnce([ + { + id: 'indexPattern1', + attributes: { + title: 'indexPattern1', + }, + }, + { + id: 'indexPattern2', + attributes: { + title: 'indexPattern2', + }, + }, + ]); + const { getFields } = jest.requireMock('../../../../common/index_controls'); + getFields.mockResolvedValueOnce([ + { + type: 'date', + name: 'test1', + }, + { + type: 'text', + name: 'test2', + }, + ]); + + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test', + refresh: false, + executionTimeField: 'test1', + }, + } as EsIndexActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + http={deps!.http} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="connectorIndexesComboBox"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); + + const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(indexSearchBoxValue.first().props().value).toEqual(''); + + const indexComboBox = wrapper.find('#indexConnectorSelectSearchBox'); + indexComboBox.first().simulate('click'); + const event = { target: { value: 'indexPattern1' } }; + indexComboBox + .find('input') + .first() + .simulate('change', event); + + const indexSearchBoxValueBeforeEnterData = wrapper.find( + '[data-test-subj="comboBoxSearchInput"]' + ); + expect(indexSearchBoxValueBeforeEnterData.first().props().value).toEqual('indexPattern1'); + + const indexComboBoxClear = wrapper.find('[data-test-subj="comboBoxClearButton"]'); + indexComboBoxClear.first().simulate('click'); + + const indexSearchBoxValueAfterEnterData = wrapper.find( + '[data-test-subj="comboBoxSearchInput"]' + ); + expect(indexSearchBoxValueAfterEnterData.first().props().value).toEqual('indexPattern1'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx similarity index 66% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index 861d6ad7284c..9cd3a1854534 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiFormRow, EuiSwitch, EuiSpacer, - EuiCodeEditor, EuiComboBox, EuiComboBoxOptionOption, EuiSelect, @@ -17,64 +16,15 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { useXJsonMode } from '../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { IndexActionParams, EsIndexActionConnector } from './types'; -import { getTimeFieldOptions } from '../../../common/lib/get_time_options'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { EsIndexActionConnector } from '.././types'; +import { getTimeFieldOptions } from '../../../../common/lib/get_time_options'; import { firstFieldOption, getFields, getIndexOptions, getIndexPatterns, -} from '../../../common/index_controls'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.index', - iconClass: 'indexOpen', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', - { - defaultMessage: 'Index data into Elasticsearch.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle', - { - defaultMessage: 'Index data', - } - ), - validateConnector: (action: EsIndexActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - index: new Array(), - }; - validationResult.errors = errors; - if (!action.config.index) { - errors.index.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.error.requiredIndexText', - { - defaultMessage: 'Index is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: IndexActionConnectorFields, - actionParamsFields: IndexParamsFields, - validateParams: (): ValidationResult => { - return { errors: {} }; - }, - }; -} +} from '../../../../common/index_controls'; const IndexActionConnectorFields: React.FunctionComponent> = ({ - actionParams, - index, - editAction, - messageVariables, -}) => { - const { documents } = actionParams; - const { xJsonMode, convertToJson, setXJson, xJson } = useXJsonMode( - documents && documents.length > 0 ? documents[0] : null - ); - const onSelectMessageVariable = (variable: string) => { - const value = (xJson ?? '').concat(` {{${variable}}}`); - setXJson(value); - // Keep the documents in sync with the editor content - onDocumentsChange(convertToJson(value)); - }; - - function onDocumentsChange(updatedDocuments: string) { - try { - const documentsJSON = JSON.parse(updatedDocuments); - editAction('documents', [documentsJSON], index); - // eslint-disable-next-line no-empty - } catch (e) {} - } - return ( - - onSelectMessageVariable(variable)} - paramsProperty="documents" - /> - } - > - { - setXJson(xjson); - // Keep the documents in sync with the editor content - onDocumentsChange(convertToJson(xjson)); - }} - /> - - - ); -}; - // if the string == null or is empty, return null, else return string function nullableString(str: string | null | undefined) { if (str == null || str.trim() === '') return null; return str; } + +// eslint-disable-next-line import/no-default-export +export { IndexActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx new file mode 100644 index 000000000000..5f05a56a228e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import ParamsFields from './es_index_params'; + +describe('IndexParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + documents: [{ test: 123 }], + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect( + wrapper + .find('[data-test-subj="actionIndexDoc"]') + .first() + .prop('value') + ).toBe(`{ + "test": 123 +}`); + expect(wrapper.find('[data-test-subj="documentsAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx new file mode 100644 index 000000000000..0b095cdc2698 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useXJsonMode } from '../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; +import { ActionParamsProps } from '../../../../types'; +import { IndexActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; + +export const IndexParamsFields = ({ + actionParams, + index, + editAction, + messageVariables, +}: ActionParamsProps) => { + const { documents } = actionParams; + const { xJsonMode, convertToJson, setXJson, xJson } = useXJsonMode( + documents && documents.length > 0 ? documents[0] : null + ); + const onSelectMessageVariable = (variable: string) => { + const value = (xJson ?? '').concat(` {{${variable}}}`); + setXJson(value); + // Keep the documents in sync with the editor content + onDocumentsChange(convertToJson(value)); + }; + + function onDocumentsChange(updatedDocuments: string) { + try { + const documentsJSON = JSON.parse(updatedDocuments); + editAction('documents', [documentsJSON], index); + // eslint-disable-next-line no-empty + } catch (e) {} + } + return ( + + onSelectMessageVariable(variable)} + paramsProperty="documents" + /> + } + > + { + setXJson(xjson); + // Keep the documents in sync with the editor content + onDocumentsChange(convertToJson(xjson)); + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { IndexParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/index.ts new file mode 100644 index 000000000000..6a2ebd9c4bc7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getIndexActionType } from './es_index'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 6ffd9b2c9ffd..8f49fa46dd54 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getActionType as getServerLogActionType } from './server_log'; -import { getActionType as getSlackActionType } from './slack'; -import { getActionType as getEmailActionType } from './email'; -import { getActionType as getIndexActionType } from './es_index'; -import { getActionType as getPagerDutyActionType } from './pagerduty'; -import { getActionType as getWebhookActionType } from './webhook'; +import { getServerLogActionType } from './server_log'; +import { getSlackActionType } from './slack'; +import { getEmailActionType } from './email'; +import { getIndexActionType } from './es_index'; +import { getPagerDutyActionType } from './pagerduty'; +import { getWebhookActionType } from './webhook'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx deleted file mode 100644 index f628457dc516..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.test.tsx +++ /dev/null @@ -1,200 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { - PagerDutyActionParams, - EventActionOptions, - SeverityActionOptions, - PagerDutyActionConnector, -} from './types'; - -const ACTION_TYPE_ID = '.pagerduty'; -let actionTypeModel: ActionTypeModel; -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('test-file-stub'); - }); -}); - -describe('pagerduty connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - routingKey: 'test', - }, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: [], - }, - }); - - delete actionConnector.config.apiUrl; - actionConnector.secrets.routingKey = 'test1'; - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - routingKey: ['A routing key is required.'], - }, - }); - }); -}); - -describe('pagerduty action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - eventAction: 'trigger', - dedupKey: 'test', - summary: '2323', - source: 'source', - severity: 'critical', - timestamp: new Date().toISOString(), - component: 'test', - group: 'group', - class: 'test class', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - summary: [], - timestamp: [], - }, - }); - }); -}); - -describe('PagerDutyActionConnectorFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - routingKey: 'test', - }, - id: 'test', - actionTypeId: '.pagerduty', - name: 'pagerduty', - config: { - apiUrl: 'http:\\test', - }, - } as PagerDutyActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - docLinks={deps!.docLinks} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="pagerdutyApiUrlInput"]') - .first() - .prop('value') - ).toBe('http:\\test'); - expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('PagerDutyParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - eventAction: EventActionOptions.TRIGGER, - dedupKey: 'test', - summary: '2323', - source: 'source', - severity: SeverityActionOptions.CRITICAL, - timestamp: new Date().toISOString(), - component: 'test', - group: 'group', - class: 'test class', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="severitySelect"]') - .first() - .prop('value') - ).toStrictEqual('critical'); - expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="pagerdutySummaryInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy(); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/index.ts new file mode 100644 index 000000000000..9128ec81391a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getPagerDutyActionType } from './pagerduty'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.svg b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.svg similarity index 100% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.svg rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.svg diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx new file mode 100644 index 000000000000..ba7eb598c120 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { PagerDutyActionConnector } from '.././types'; + +const ACTION_TYPE_ID = '.pagerduty'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('test-file-stub'); + }); +}); + +describe('pagerduty connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + + delete actionConnector.config.apiUrl; + actionConnector.secrets.routingKey = 'test1'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: ['A routing key is required.'], + }, + }); + }); +}); + +describe('pagerduty action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + summary: [], + timestamp: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx new file mode 100644 index 000000000000..5e29fca39718 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { PagerDutyActionParams, PagerDutyActionConnector } from '.././types'; +import pagerDutySvg from './pagerduty.svg'; +import { hasMustacheTokens } from '../../../lib/has_mustache_tokens'; + +export function getActionType(): ActionTypeModel { + return { + id: '.pagerduty', + iconClass: pagerDutySvg, + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', + { + defaultMessage: 'Send an event in PagerDuty.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle', + { + defaultMessage: 'Send to PagerDuty', + } + ), + validateConnector: (action: PagerDutyActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + routingKey: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.routingKey) { + errors.routingKey.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'A routing key is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + summary: new Array(), + timestamp: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.summary?.length) { + errors.summary.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', + { + defaultMessage: 'Summary is required.', + } + ) + ); + } + if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { + if (isNaN(Date.parse(actionParams.timestamp))) { + const { nowShortFormat, nowLongFormat } = getValidTimestampExamples(); + errors.timestamp.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.invalidTimestamp', + { + defaultMessage: + 'Timestamp must be a valid date, such as {nowShortFormat} or {nowLongFormat}.', + values: { + nowShortFormat, + nowLongFormat, + }, + } + ) + ); + } + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./pagerduty_connectors')), + actionParamsFields: lazy(() => import('./pagerduty_params')), + }; +} + +function getValidTimestampExamples() { + const now = moment(); + return { + nowShortFormat: now.format('YYYY-MM-DD'), + nowLongFormat: now.format('YYYY-MM-DD h:mm:ss'), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx new file mode 100644 index 000000000000..3f3fba1599bd --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { PagerDutyActionConnector } from '.././types'; +import PagerDutyActionConnectorFields from './pagerduty_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('PagerDutyActionConnectorFields renders', () => { + test('all connector fields is rendered', async () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as PagerDutyActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="pagerdutyApiUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx new file mode 100644 index 000000000000..48da3f1778b4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { PagerDutyActionConnector } from '.././types'; + +const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets, docLinks }) => { + const { apiUrl } = action.config; + const { routingKey } = action.secrets; + return ( + + + ) => { + editActionConfig('apiUrl', e.target.value); + }} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + +
      + } + error={errors.routingKey} + isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', + { + defaultMessage: 'Integration key', + } + )} + > + 0 && routingKey !== undefined} + name="routingKey" + value={routingKey || ''} + data-test-subj="pagerdutyRoutingKeyInput" + onChange={(e: React.ChangeEvent) => { + editActionSecrets('routingKey', e.target.value); + }} + onBlur={() => { + if (!routingKey) { + editActionSecrets('routingKey', ''); + } + }} + /> +
      + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { PagerDutyActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx new file mode 100644 index 000000000000..d1b32f545c33 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EventActionOptions, SeverityActionOptions } from '.././types'; +import PagerDutyParamsFields from './pagerduty_params'; + +describe('PagerDutyParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + eventAction: EventActionOptions.TRIGGER, + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: SeverityActionOptions.CRITICAL, + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="severitySelect"]') + .first() + .prop('value') + ).toStrictEqual('critical'); + expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="pagerdutySummaryInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx similarity index 67% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx index 5ad1f2fffecc..590eba5dad93 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.tsx @@ -4,178 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; -import { - EuiFieldText, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiLink, -} from '@elastic/eui'; +import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import moment from 'moment'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { PagerDutyActionParams, PagerDutyActionConnector } from './types'; -import pagerDutySvg from './pagerduty.svg'; -import { AddMessageVariables } from '../add_message_variables'; -import { hasMustacheTokens } from '../../lib/has_mustache_tokens'; - -export function getActionType(): ActionTypeModel { - return { - id: '.pagerduty', - iconClass: pagerDutySvg, - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', - { - defaultMessage: 'Send an event in PagerDuty.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.actionTypeTitle', - { - defaultMessage: 'Send to PagerDuty', - } - ), - validateConnector: (action: PagerDutyActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - routingKey: new Array(), - }; - validationResult.errors = errors; - if (!action.secrets.routingKey) { - errors.routingKey.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', - { - defaultMessage: 'A routing key is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: PagerDutyActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - summary: new Array(), - timestamp: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.summary?.length) { - errors.summary.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredSummaryText', - { - defaultMessage: 'Summary is required.', - } - ) - ); - } - if (actionParams.timestamp && !hasMustacheTokens(actionParams.timestamp)) { - if (isNaN(Date.parse(actionParams.timestamp))) { - const { nowShortFormat, nowLongFormat } = getValidTimestampExamples(); - errors.timestamp.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.invalidTimestamp', - { - defaultMessage: - 'Timestamp must be a valid date, such as {nowShortFormat} or {nowLongFormat}.', - values: { - nowShortFormat, - nowLongFormat, - }, - } - ) - ); - } - } - return validationResult; - }, - actionConnectorFields: PagerDutyActionConnectorFields, - actionParamsFields: PagerDutyParamsFields, - }; -} - -const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets, docLinks }) => { - const { apiUrl } = action.config; - const { routingKey } = action.secrets; - return ( - - - ) => { - editActionConfig('apiUrl', e.target.value); - }} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - /> - - - - - } - error={errors.routingKey} - isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', - { - defaultMessage: 'Integration key', - } - )} - > - 0 && routingKey !== undefined} - name="routingKey" - value={routingKey || ''} - data-test-subj="pagerdutyRoutingKeyInput" - onChange={(e: React.ChangeEvent) => { - editActionSecrets('routingKey', e.target.value); - }} - onBlur={() => { - if (!routingKey) { - editActionSecrets('routingKey', ''); - } - }} - /> - - - ); -}; +import { ActionParamsProps } from '../../../../types'; +import { PagerDutyActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; const PagerDutyParamsFields: React.FunctionComponent> = ({ actionParams, @@ -561,10 +394,5 @@ const PagerDutyParamsFields: React.FunctionComponent { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logsApp'); - }); -}); - -describe('server-log connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.server-log', - name: 'server-log', - config: {}, - } as ActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: {}, - }); - }); -}); - -describe('action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - message: 'test message', - level: 'trace', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { message: [] }, - }); - }); -}); - -describe('ServerLogParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - level: ServerLogLevelOptions.TRACE, - message: 'test', - }; - const wrapper = mountWithIntl( - {}} - index={0} - defaultMessage={'test default message'} - /> - ); - expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="loggingLevelSelect"]') - .first() - .prop('value') - ).toStrictEqual('trace'); - expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); - }); - - test('level param field is rendered with default value if not selected', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - message: 'test message', - level: ServerLogLevelOptions.INFO, - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="loggingLevelSelect"]') - .first() - .prop('value') - ).toStrictEqual('info'); - expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); - }); - - test('params validation fails when message is not valid', () => { - const actionParams = { - message: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - message: ['Message is required.'], - }, - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/index.ts new file mode 100644 index 000000000000..f85c7460d2ec --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getServerLogActionType } from './server_log'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx new file mode 100644 index 000000000000..3bb5ea68a304 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel, ActionConnector } from '../../../../types'; + +const ACTION_TYPE_ID = '.server-log'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logsApp'); + }); +}); + +describe('server-log connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.server-log', + name: 'server-log', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'test message', + level: 'trace', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx new file mode 100644 index 000000000000..390ccf6a494e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { ServerLogActionParams } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.server-log', + iconClass: 'logsApp', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', + { + defaultMessage: 'Add a message to a Kibana log.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.actionTypeTitle', + { + defaultMessage: 'Send to Server log', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (actionParams: ServerLogActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: lazy(() => import('./server_log_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx new file mode 100644 index 000000000000..d2e1d1e4500b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ServerLogLevelOptions } from '.././types'; +import ServerLogParamsFields from './server_log_params'; + +describe('ServerLogParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + level: ServerLogLevelOptions.TRACE, + message: 'test', + }; + const wrapper = mountWithIntl( + {}} + index={0} + defaultMessage={'test default message'} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('trace'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('level param field is rendered with default value if not selected', () => { + const actionParams = { + message: 'test message', + level: ServerLogLevelOptions.INFO, + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('info'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.tsx similarity index 67% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.tsx index a4c83ce76f04..64d39e238be7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.tsx @@ -6,51 +6,9 @@ import React, { Fragment, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui'; -import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types'; -import { ServerLogActionParams } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.server-log', - iconClass: 'logsApp', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', - { - defaultMessage: 'Add a message to a Kibana log.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.actionTypeTitle', - { - defaultMessage: 'Send to Server log', - } - ), - validateConnector: (): ValidationResult => { - return { errors: {} }; - }, - validateParams: (actionParams: ServerLogActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - message: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: null, - actionParamsFields: ServerLogParamsFields, - }; -} +import { ActionParamsProps } from '../../../../types'; +import { ServerLogActionParams } from '.././types'; +import { AddMessageVariables } from '../../add_message_variables'; export const ServerLogParamsFields: React.FunctionComponent ); }; + +// eslint-disable-next-line import/no-default-export +export { ServerLogParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx deleted file mode 100644 index a2865b27bc06..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.test.tsx +++ /dev/null @@ -1,166 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { FunctionComponent } from 'react'; -import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { act } from 'react-dom/test-utils'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { SlackActionParams, SlackActionConnector } from './types'; - -const ACTION_TYPE_ID = '.slack'; -let actionTypeModel: ActionTypeModel; - -let deps: any; - -beforeAll(async () => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } - deps = { - docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, - }; -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logoSlack'); - }); -}); - -describe('slack connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - webhookUrl: 'http:\\test', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - webhookUrl: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: {}, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - webhookUrl: ['Webhook URL is required.'], - }, - }); - }); -}); - -describe('slack action params validation', () => { - test('if action params validation succeeds when action params is valid', () => { - const actionParams = { - message: 'message {test}', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { message: [] }, - }); - }); -}); - -describe('SlackActionFields renders', () => { - test('all connector fields is rendered', async () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - webhookUrl: 'http:\\test', - }, - id: 'test', - actionTypeId: '.email', - name: 'email', - config: {}, - } as SlackActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - docLinks={deps!.docLinks} - /> - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="slackWebhookUrlInput"]') - .first() - .prop('value') - ).toBe('http:\\test'); - }); -}); - -describe('SlackParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - message: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="slackMessageTextArea"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="slackMessageTextArea"]') - .first() - .prop('value') - ).toStrictEqual('test message'); - }); - - test('params validation fails when message is not valid', () => { - const actionParams = { - message: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - message: ['Message is required.'], - }, - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx deleted file mode 100644 index 03f7a2f492d5..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ /dev/null @@ -1,188 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { Fragment, useEffect } from 'react'; -import { EuiFieldText, EuiTextArea, EuiFormRow, EuiLink } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { SlackActionParams, SlackActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; - -export function getActionType(): ActionTypeModel { - return { - id: '.slack', - iconClass: 'logoSlack', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', - { - defaultMessage: 'Send a message to a Slack channel or user.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle', - { - defaultMessage: 'Send to Slack', - } - ), - validateConnector: (action: SlackActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - webhookUrl: new Array(), - }; - validationResult.errors = errors; - if (!action.secrets.webhookUrl) { - errors.webhookUrl.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', - { - defaultMessage: 'Webhook URL is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: SlackActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - message: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.message?.length) { - errors.message.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', - { - defaultMessage: 'Message is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: SlackActionFields, - actionParamsFields: SlackParamsFields, - }; -} - -const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors, docLinks }) => { - const { webhookUrl } = action.secrets; - - return ( - - - - - } - error={errors.webhookUrl} - isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', - { - defaultMessage: 'Webhook URL', - } - )} - > - 0 && webhookUrl !== undefined} - name="webhookUrl" - placeholder="Example: https://hooks.slack.com/services" - value={webhookUrl || ''} - data-test-subj="slackWebhookUrlInput" - onChange={e => { - editActionSecrets('webhookUrl', e.target.value); - }} - onBlur={() => { - if (!webhookUrl) { - editActionSecrets('webhookUrl', ''); - } - }} - /> - - - ); -}; - -const SlackParamsFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - errors, - messageVariables, - defaultMessage, -}) => { - const { message } = actionParams; - useEffect(() => { - if (!message && defaultMessage && defaultMessage.length > 0) { - editAction('message', defaultMessage, index); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction(paramsProperty, (message ?? '').concat(` {{${variable}}}`), index); - }; - - return ( - - 0 && message !== undefined} - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel', - { - defaultMessage: 'Message', - } - )} - labelAppend={ - - onSelectMessageVariable('message', variable) - } - paramsProperty="message" - /> - } - > - 0 && message !== undefined} - name="message" - value={message || ''} - data-test-subj="slackMessageTextArea" - onChange={e => { - editAction('message', e.target.value, index); - }} - onBlur={() => { - if (!message) { - editAction('message', '', index); - } - }} - /> - - - ); -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/index.ts new file mode 100644 index 000000000000..64ab6670754c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getSlackActionType } from './slack'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx new file mode 100644 index 000000000000..78f4161cac82 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { SlackActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.slack'; +let actionTypeModel: ActionTypeModel; + +beforeAll(async () => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('slack connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is required.'], + }, + }); + }); +}); + +describe('slack action params validation', () => { + test('if action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx new file mode 100644 index 000000000000..5d39cdb5ac38 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { SlackActionParams, SlackActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.slack', + iconClass: 'logoSlack', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.actionTypeTitle', + { + defaultMessage: 'Send to Slack', + } + ), + validateConnector: (action: SlackActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + webhookUrl: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.webhookUrl) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: SlackActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./slack_connectors')), + actionParamsFields: lazy(() => import('./slack_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx new file mode 100644 index 000000000000..7d7f6fc08692 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { act } from '@testing-library/react'; +import { SlackActionConnector } from '../types'; +import SlackActionFields from './slack_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('SlackActionFields renders', () => { + test('all connector fields is rendered', async () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as SlackActionConnector; + const deps = { + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart, + }; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={deps!.docLinks} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackWebhookUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx new file mode 100644 index 000000000000..ad3e76ad8ae6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { SlackActionConnector } from '../types'; + +const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors, docLinks }) => { + const { webhookUrl } = action.secrets; + + return ( + + + + + } + error={errors.webhookUrl} + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', + { + defaultMessage: 'Webhook URL', + } + )} + > + 0 && webhookUrl !== undefined} + name="webhookUrl" + placeholder="Example: https://hooks.slack.com/services" + value={webhookUrl || ''} + data-test-subj="slackWebhookUrlInput" + onChange={e => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackActionFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx new file mode 100644 index 000000000000..4183aeb48dec --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import SlackParamsFields from './slack_params'; + +describe('SlackParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="slackMessageTextArea"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackMessageTextArea"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx new file mode 100644 index 000000000000..42fefdd41ef6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_params.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useEffect } from 'react'; +import { EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { SlackActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +const SlackParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, +}) => { + const { message } = actionParams; + useEffect(() => { + if (!message && defaultMessage && defaultMessage.length > 0) { + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction(paramsProperty, (message ?? '').concat(` {{${variable}}}`), index); + }; + + return ( + + 0 && message !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.messageTextAreaFieldLabel', + { + defaultMessage: 'Message', + } + )} + labelAppend={ + + onSelectMessageVariable('message', variable) + } + paramsProperty="message" + /> + } + > + 0 && message !== undefined} + name="message" + value={message || ''} + data-test-subj="slackMessageTextArea" + onChange={e => { + editAction('message', e.target.value, index); + }} + onBlur={() => { + if (!message) { + editAction('message', '', index); + } + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx deleted file mode 100644 index 7d0082708075..000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.test.tsx +++ /dev/null @@ -1,179 +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; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { FunctionComponent } from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { TypeRegistry } from '../../type_registry'; -import { registerBuiltInActionTypes } from './index'; -import { ActionTypeModel, ActionParamsProps } from '../../../types'; -import { WebhookActionParams, WebhookActionConnector } from './types'; - -const ACTION_TYPE_ID = '.webhook'; -let actionTypeModel: ActionTypeModel; - -beforeAll(() => { - const actionTypeRegistry = new TypeRegistry(); - registerBuiltInActionTypes({ actionTypeRegistry }); - const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); - if (getResult !== null) { - actionTypeModel = getResult; - } -}); - -describe('actionTypeRegistry.get() works', () => { - test('action type static data is as expected', () => { - expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); - expect(actionTypeModel.iconClass).toEqual('logoWebhook'); - }); -}); - -describe('webhook connector validation', () => { - test('connector validation succeeds when connector config is valid', () => { - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - name: 'webhook', - isPreconfigured: false, - config: { - method: 'PUT', - url: 'http:\\test', - headers: { 'content-type': 'text' }, - }, - } as WebhookActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - url: [], - method: [], - user: [], - password: [], - }, - }); - }); - - test('connector validation fails when connector config is not valid', () => { - const actionConnector = { - secrets: { - user: 'user', - }, - id: 'test', - actionTypeId: '.webhook', - name: 'webhook', - config: { - method: 'PUT', - }, - } as WebhookActionConnector; - - expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ - errors: { - url: ['URL is required.'], - method: [], - user: [], - password: ['Password is required.'], - }, - }); - }); -}); - -describe('webhook action params validation', () => { - test('action params validation succeeds when action params is valid', () => { - const actionParams = { - body: 'message {test}', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { body: [] }, - }); - }); -}); - -describe('WebhookActionConnectorFields renders', () => { - test('all connector fields is rendered', () => { - expect(actionTypeModel.actionConnectorFields).not.toBeNull(); - if (!actionTypeModel.actionConnectorFields) { - return; - } - const ConnectorFields = actionTypeModel.actionConnectorFields; - const actionConnector = { - secrets: { - user: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - isPreconfigured: false, - name: 'webhook', - config: { - method: 'PUT', - url: 'http:\\test', - headers: { 'content-type': 'text' }, - }, - } as WebhookActionConnector; - const wrapper = mountWithIntl( - {}} - editActionSecrets={() => {}} - /> - ); - expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); - wrapper - .find('[data-test-subj="webhookViewHeadersSwitch"]') - .first() - .simulate('click'); - expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); - }); -}); - -describe('WebhookParamsFields renders', () => { - test('all params fields is rendered', () => { - expect(actionTypeModel.actionParamsFields).not.toBeNull(); - if (!actionTypeModel.actionParamsFields) { - return; - } - const ParamsFields = actionTypeModel.actionParamsFields as FunctionComponent< - ActionParamsProps - >; - const actionParams = { - body: 'test message', - }; - const wrapper = mountWithIntl( - {}} - index={0} - /> - ); - expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="webhookBodyEditor"]') - .first() - .prop('value') - ).toStrictEqual('test message'); - expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').length > 0).toBeTruthy(); - }); - - test('params validation fails when body is not valid', () => { - const actionParams = { - body: '', - }; - - expect(actionTypeModel.validateParams(actionParams)).toEqual({ - errors: { - body: ['Body is required.'], - }, - }); - }); -}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/index.ts new file mode 100644 index 000000000000..c43cab26b072 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as getWebhookActionType } from './webhook'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx new file mode 100644 index 000000000000..3413465d70d9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { WebhookActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.webhook'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoWebhook'); + }); +}); + +describe('webhook connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: false, + config: { + method: 'PUT', + url: 'http:\\test', + headers: { 'content-type': 'text' }, + }, + } as WebhookActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: [], + method: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + }, + } as WebhookActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: ['URL is required.'], + method: [], + user: [], + password: ['Password is required.'], + }, + }); + }); +}); + +describe('webhook action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + body: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: [] }, + }); + }); + + test('params validation fails when body is not valid', () => { + const actionParams = { + body: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: ['Body is required.'], + }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx new file mode 100644 index 000000000000..9f33e4491233 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionTypeModel, ValidationResult } from '../../../../types'; +import { WebhookActionParams, WebhookActionConnector } from '../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.webhook', + iconClass: 'logoWebhook', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', + { + defaultMessage: 'Send a request to a web service.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', + { + defaultMessage: 'Webhook data', + } + ), + validateConnector: (action: WebhookActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + url: new Array(), + method: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.url) { + errors.url.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } + ) + ); + } + if (!action.config.method) { + errors.method.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } + ) + ); + } + if (!action.secrets.user && action.secrets.password) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password && action.secrets.user) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: WebhookActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + body: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.body?.length) { + errors.body.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./webhook_connectors')), + actionParamsFields: lazy(() => import('./webhook_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx new file mode 100644 index 000000000000..842ec5178535 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { WebhookActionConnector } from '../types'; +import WebhookActionConnectorFields from './webhook_connectors'; +import { DocLinksStart } from 'kibana/public'; + +describe('WebhookActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + isPreconfigured: false, + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: { 'content-type': 'text' }, + }, + } as WebhookActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + /> + ); + expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); + wrapper + .find('[data-test-subj="webhookViewHeadersSwitch"]') + .first() + .simulate('click'); + expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx similarity index 71% rename from x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx rename to x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index daa5a6caeabe..e163463602d9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -19,112 +19,15 @@ import { EuiDescriptionListDescription, EuiDescriptionListTitle, EuiTitle, - EuiCodeEditor, EuiSwitch, EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { - ActionTypeModel, - ActionConnectorFieldsProps, - ValidationResult, - ActionParamsProps, -} from '../../../types'; -import { WebhookActionParams, WebhookActionConnector } from './types'; -import { AddMessageVariables } from '../add_message_variables'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { WebhookActionConnector } from '../types'; const HTTP_VERBS = ['post', 'put']; -export function getActionType(): ActionTypeModel { - return { - id: '.webhook', - iconClass: 'logoWebhook', - selectMessage: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', - { - defaultMessage: 'Send a request to a web service.', - } - ), - actionTypeTitle: i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', - { - defaultMessage: 'Webhook data', - } - ), - validateConnector: (action: WebhookActionConnector): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - url: new Array(), - method: new Array(), - user: new Array(), - password: new Array(), - }; - validationResult.errors = errors; - if (!action.config.url) { - errors.url.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', - { - defaultMessage: 'URL is required.', - } - ) - ); - } - if (!action.config.method) { - errors.method.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', - { - defaultMessage: 'Method is required.', - } - ) - ); - } - if (!action.secrets.user && action.secrets.password) { - errors.user.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', - { - defaultMessage: 'Username is required.', - } - ) - ); - } - if (!action.secrets.password && action.secrets.user) { - errors.password.push( - i18n.translate( - 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', - { - defaultMessage: 'Password is required.', - } - ) - ); - } - return validationResult; - }, - validateParams: (actionParams: WebhookActionParams): ValidationResult => { - const validationResult = { errors: {} }; - const errors = { - body: new Array(), - }; - validationResult.errors = errors; - if (!actionParams.body?.length) { - errors.body.push( - i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', - { - defaultMessage: 'Body is required.', - } - ) - ); - } - return validationResult; - }, - actionConnectorFields: WebhookActionConnectorFields, - actionParamsFields: WebhookParamsFields, - }; -} - const WebhookActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { @@ -457,56 +360,5 @@ const WebhookActionConnectorFields: React.FunctionComponent> = ({ - actionParams, - editAction, - index, - messageVariables, - errors, -}) => { - const { body } = actionParams; - const onSelectMessageVariable = (paramsProperty: string, variable: string) => { - editAction(paramsProperty, (body ?? '').concat(` {{${variable}}}`), index); - }; - return ( - - 0 && body !== undefined} - fullWidth - error={errors.body} - labelAppend={ - onSelectMessageVariable('body', variable)} - paramsProperty="body" - /> - } - > - { - editAction('body', json, index); - }} - /> - - - ); -}; +// eslint-disable-next-line import/no-default-export +export { WebhookActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx new file mode 100644 index 000000000000..5ca27a53083f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import WebhookParamsFields from './webhook_params'; + +describe('WebhookParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + body: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + /> + ); + expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="webhookBodyEditor"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx new file mode 100644 index 000000000000..9e802b96e16b --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFormRow, EuiCodeEditor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionParamsProps } from '../../../../types'; +import { WebhookActionParams } from '../types'; +import { AddMessageVariables } from '../../add_message_variables'; + +const WebhookParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + errors, +}) => { + const { body } = actionParams; + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editAction(paramsProperty, (body ?? '').concat(` {{${variable}}}`), index); + }; + return ( + + 0 && body !== undefined} + fullWidth + error={errors.body} + labelAppend={ + onSelectMessageVariable('body', variable)} + paramsProperty="body" + /> + } + > + { + editAction('body', json, index); + }} + /> + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { WebhookParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 4d0a9980f223..b5f3b63c58a9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -167,3 +167,6 @@ export const TriggersActionsUIHome: React.FunctionComponent ); }; + +// eslint-disable-next-line import/no-default-export +export { TriggersActionsUIHome as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 6bb8a8f4e4c1..06ddce39567a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, Suspense } from 'react'; import { EuiForm, EuiCallOut, @@ -12,6 +12,9 @@ import { EuiSpacer, EuiFieldText, EuiFormRow, + EuiLoadingSpinner, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -151,14 +154,24 @@ export const ActionConnectorForm = ({ {FieldsComponent !== null ? ( - + + + + + + } + > + + ) : null}
      ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 6935dda358d9..ae179f56f0c8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect } from 'react'; +import React, { Fragment, Suspense, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -27,6 +27,7 @@ import { EuiCallOut, EuiHorizontalRule, EuiText, + EuiLoadingSpinner, } from '@elastic/eui'; import { HttpSetup, ToastsApi, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; @@ -282,14 +283,24 @@ export const ActionForm = ({ {ParamsFieldsComponent ? ( - + + + + + + } + > + + ) : null} ) : ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx index 9198607df786..0caa880c4df0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details_route.tsx @@ -118,6 +118,6 @@ export async function getAlertData( } } -export const AlertDetailsRouteWithApi = withActionOperations( - withBulkAlertOperations(AlertDetailsRoute) -); +const AlertDetailsRouteWithApi = withActionOperations(withBulkAlertOperations(AlertDetailsRoute)); +// eslint-disable-next-line import/no-default-export +export { AlertDetailsRouteWithApi as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts index 93e61cf5b4f4..62173a6196b9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/type_registry.test.ts @@ -23,7 +23,11 @@ const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { }; }; -const getTestActionType = (id?: string, iconClass?: string, selectedMessage?: string) => { +const getTestActionType = ( + id?: string, + iconClass?: string, + selectedMessage?: string +): ActionTypeModel => { return { id: id || 'my-action-type', iconClass: iconClass || 'test', diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6f33bcb8b226..cc511434267c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { HttpSetup, DocLinksStart } from 'kibana/public'; +import { ComponentType } from 'react'; import { ActionGroup } from '../../alerting/common'; import { ActionType } from '../../actions/common'; import { TypeRegistry } from './application/type_registry'; @@ -19,14 +20,16 @@ export { ActionType }; export type ActionTypeIndex = Record; export type AlertTypeIndex = Record; -export type ActionTypeRegistryContract = PublicMethodsOf>; +export type ActionTypeRegistryContract = PublicMethodsOf< + TypeRegistry> +>; export type AlertTypeRegistryContract = PublicMethodsOf>; export interface ActionConnectorFieldsProps { action: TActionConnector; editActionConfig: (property: string, value: any) => void; editActionSecrets: (property: string, value: any) => void; - errors: { [key: string]: string[] }; + errors: IErrorObject; docLinks: DocLinksStart; http?: HttpSetup; } @@ -35,7 +38,7 @@ export interface ActionParamsProps { actionParams: TParams; index: number; editAction: (property: string, value: any, index: number) => void; - errors: { [key: string]: string[] }; + errors: IErrorObject; messageVariables?: string[]; defaultMessage?: string; } @@ -45,15 +48,19 @@ export interface Pagination { size: number; } -export interface ActionTypeModel { +export interface ActionTypeModel { id: string; iconClass: string; selectMessage: string; actionTypeTitle?: string; validateConnector: (connector: any) => ValidationResult; validateParams: (actionParams: any) => ValidationResult; - actionConnectorFields: React.FunctionComponent | null; - actionParamsFields: any; + actionConnectorFields: React.LazyExoticComponent< + ComponentType> + > | null; + actionParamsFields: React.LazyExoticComponent< + ComponentType> + > | null; } export interface ValidationResult { diff --git a/x-pack/plugins/uptime/README.md b/x-pack/plugins/uptime/README.md index 92162341ff42..10c1fc0edcd0 100644 --- a/x-pack/plugins/uptime/README.md +++ b/x-pack/plugins/uptime/README.md @@ -75,3 +75,19 @@ We can run these tests like described above, but with some special config. `node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts` `node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts` + +#### Running 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 Kibana (for example, users relying +on screen readers). + +The commands for running these tests are very similar to the other functional tests described above. + +From the `~/x-pack` directory: + +Start the server: `node scripts/functional_tests_server --config test/accessibility/config.ts` + +Run the uptime `a11y` tests: `node scripts/functional_test_runner.js --config test/accessibility/config.ts --grep=uptime` diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index c64ca7c3d484..c6a7eb261d8f 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -12,7 +12,6 @@ import { import { UMFrontendLibs } from '../lib/lib'; import { PLUGIN } from '../../common/constants'; import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public'; -import { getKibanaFrameworkAdapter } from '../lib/adapters/framework/new_platform_adapter'; import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public'; import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; @@ -61,6 +60,10 @@ export class UptimePlugin implements Plugin