From 8bdc0d7b253b18b6fa45e856189ca63643cbc5db Mon Sep 17 00:00:00 2001 From: Domenico Andreoli Date: Mon, 14 Jun 2021 21:31:57 +0200 Subject: [PATCH 01/38] Fix esArchiver path in the Jenkins context (#102095) --- .../apps/alerts/alerts_encryption_keys.js | 8 +++++++- .../apps/ccs/ccs_discover.js | 10 +++++++--- .../apps/metricbeat/_metricbeat_dashboard.js | 9 +++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/x-pack/test/stack_functional_integration/apps/alerts/alerts_encryption_keys.js b/x-pack/test/stack_functional_integration/apps/alerts/alerts_encryption_keys.js index f11aa7e09635bfc..e4ac00661c078b3 100644 --- a/x-pack/test/stack_functional_integration/apps/alerts/alerts_encryption_keys.js +++ b/x-pack/test/stack_functional_integration/apps/alerts/alerts_encryption_keys.js @@ -6,8 +6,14 @@ */ import expect from '@kbn/expect'; +import { resolve } from 'path'; +import { REPO_ROOT } from '@kbn/dev-utils'; -const ARCHIVE = '../integration-test/test/es_archives/email_connectors_with_encryption_rotation'; +const INTEGRATION_TEST_ROOT = process.env.WORKSPACE || resolve(REPO_ROOT, '../integration-test'); +const ARCHIVE = resolve( + INTEGRATION_TEST_ROOT, + 'test/es_archives/email_connectors_with_encryption_rotation' +); export default ({ getPageObjects, getService }) => { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js index 00fce236b5d1724..a22e4438c7dbdd9 100644 --- a/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js +++ b/x-pack/test/stack_functional_integration/apps/ccs/ccs_discover.js @@ -6,11 +6,15 @@ */ import fs from 'fs'; +import { resolve } from 'path'; import expect from '@kbn/expect'; import { Client as EsClient } from '@elastic/elasticsearch'; import { KbnClient } from '@kbn/test'; import { EsArchiver } from '@kbn/es-archiver'; -import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { CA_CERT_PATH, REPO_ROOT } from '@kbn/dev-utils'; + +const INTEGRATION_TEST_ROOT = process.env.WORKSPACE || resolve(REPO_ROOT, '../integration-test'); +const ARCHIVE = resolve(INTEGRATION_TEST_ROOT, 'test/es_archives/metricbeat'); export default ({ getService, getPageObjects }) => { describe('Cross cluster search test in discover', async () => { @@ -261,7 +265,7 @@ export default ({ getService, getPageObjects }) => { before('Prepare data:metricbeat-*', async function () { log.info('Create index'); - await esArchiver.load('../integration-test/test/es_archives/metricbeat'); + await esArchiver.load(ARCHIVE); log.info('Create index pattern'); dataId = await supertest @@ -321,7 +325,7 @@ export default ({ getService, getPageObjects }) => { } log.info('Delete index'); - await esArchiver.unload('../integration-test/test/es_archives/metricbeat'); + await esArchiver.unload(ARCHIVE); }); after('Clean up .siem-signal-*', async function () { diff --git a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js index 2b25d5ffea6e1ab..b678e88bcf0dfff 100644 --- a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js +++ b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js @@ -6,6 +6,11 @@ */ import expect from '@kbn/expect'; +import { resolve } from 'path'; +import { REPO_ROOT } from '@kbn/dev-utils'; + +const INTEGRATION_TEST_ROOT = process.env.WORKSPACE || resolve(REPO_ROOT, '../integration-test'); +const ARCHIVE = resolve(INTEGRATION_TEST_ROOT, 'test/es_archives/metricbeat'); export default function ({ getService, getPageObjects, updateBaselines }) { const screenshot = getService('screenshots'); @@ -15,7 +20,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { describe('check metricbeat Dashboard', function () { before(async function () { - await esArchiver.load('../integration-test/test/es_archives/metricbeat'); + await esArchiver.load(ARCHIVE); // this navigateToActualURL takes the place of navigating to the dashboard landing page, // filtering on the dashboard name, selecting it, setting the timepicker, and going to full screen @@ -45,7 +50,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { }); after(async function () { - await esArchiver.unload('../integration-test/test/es_archives/metricbeat'); + await esArchiver.unload(ARCHIVE); }); it('[Metricbeat System] Overview ECS should match snapshot', async function () { From 35f962526597ca7b4592b5d30b3566d770f06f41 Mon Sep 17 00:00:00 2001 From: Apoorva Joshi <30438249+ajosh0504@users.noreply.github.com> Date: Mon, 14 Jun 2021 12:36:08 -0700 Subject: [PATCH 02/38] [ML] Adds Authentication module with six ML jobs for ECS data (Auditbeat, Winlogbeat, Filebeat and Logs) (#101840) * Adding Security Authentication jobs in 7.14 * Renamed some jobs * Changing memory limits and linting change * Linting fix * Changed the order * Adding module to ml_modules.tsx * Update recognize_module.ts this test modules uses older Auditbeat data which predates the event.category field so the test has to be skipped per https://elastic.zoom.us/j/93000943632?pwd=TmpvNWhtYUNzMUc0c0N6Tlc2QlVPZz09 * Update recognize_module.ts needs to be a single line * Update recognize_module.ts Some linters want spaces and some linters want no spaces. This linter wants spaces. * descriptions added description text * Update auth_rare_hour_for_a_user.json removed a wayward newline char * Minor nitpicking * memory limits raised memory limits to 128mb which is larger than the highest observed peak model bytes for the most memory hungry jobs in this event class. Co-authored-by: Craig Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../modules/security_auth/logo.json | 3 + .../modules/security_auth/manifest.json | 77 +++++++++++++++++++ .../ml/auth_high_count_logon_events.json | 29 +++++++ ...gh_count_logon_events_for_a_source_ip.json | 34 ++++++++ .../ml/auth_high_count_logon_fails.json | 29 +++++++ .../ml/auth_rare_hour_for_a_user.json | 33 ++++++++ .../ml/auth_rare_source_ip_for_a_user.json | 34 ++++++++ .../security_auth/ml/auth_rare_user.json | 33 ++++++++ ...datafeed_auth_high_count_logon_events.json | 26 +++++++ ...gh_count_logon_events_for_a_source_ip.json | 26 +++++++ .../datafeed_auth_high_count_logon_fails.json | 26 +++++++ .../datafeed_auth_rare_hour_for_a_user.json | 26 +++++++ ...tafeed_auth_rare_source_ip_for_a_user.json | 26 +++++++ .../ml/datafeed_auth_rare_user.json | 26 +++++++ .../components/ml_popover/ml_modules.tsx | 1 + .../apis/ml/modules/get_module.ts | 1 + .../apis/ml/modules/recognize_module.ts | 5 +- 17 files changed, 433 insertions(+), 2 deletions(-) create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/logo.json create mode 100755 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_hour_for_a_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_source_ip_for_a_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_fails.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_hour_for_a_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_source_ip_for_a_user.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_user.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/logo.json new file mode 100755 index 000000000000000..862f970b7405dbc --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json new file mode 100755 index 000000000000000..480f49f3f2b1981 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/manifest.json @@ -0,0 +1,77 @@ +{ + "id": "security_auth", + "title": "Security: Authentication", + "description": "Detect anomalous activity in your ECS-compatible authentication logs.", + "type": "auth data", + "logoFile": "logo.json", + "defaultIndexPattern": "auditbeat-*,logs-*,filebeat-*,winlogbeat-*", + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + } + ] + } + }, + "jobs": [ + { + "id": "auth_high_count_logon_events_for_a_source_ip", + "file": "auth_high_count_logon_events_for_a_source_ip.json" + }, + { + "id": "auth_high_count_logon_fails", + "file": "auth_high_count_logon_fails.json" + }, + { + "id": "auth_high_count_logon_events", + "file": "auth_high_count_logon_events.json" + }, + { + "id": "auth_rare_hour_for_a_user", + "file": "auth_rare_hour_for_a_user.json" + }, + { + "id": "auth_rare_source_ip_for_a_user", + "file": "auth_rare_source_ip_for_a_user.json" + }, + { + "id": "auth_rare_user", + "file": "auth_rare_user.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-auth_high_count_logon_events_for_a_source_ip", + "file": "datafeed_auth_high_count_logon_events_for_a_source_ip.json", + "job_id": "auth_high_count_logon_events_for_a_source_ip" + }, + { + "id": "datafeed-auth_high_count_logon_fails", + "file": "datafeed_auth_high_count_logon_fails.json", + "job_id": "auth_high_count_logon_fails" + }, + { + "id": "datafeed-auth_high_count_logon_events", + "file": "datafeed_auth_high_count_logon_events.json", + "job_id": "auth_high_count_logon_events" + }, + { + "id": "datafeed-auth_rare_hour_for_a_user", + "file": "datafeed_auth_rare_hour_for_a_user.json", + "job_id": "auth_rare_hour_for_a_user" + }, + { + "id": "datafeed-auth_rare_source_ip_for_a_user", + "file": "datafeed_auth_rare_source_ip_for_a_user.json", + "job_id": "auth_rare_source_ip_for_a_user" + }, + { + "id": "datafeed-auth_rare_user", + "file": "datafeed_auth_rare_user.json", + "job_id": "auth_rare_user" + } + ] +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json new file mode 100644 index 000000000000000..ee84fb222bb5c7a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events.json @@ -0,0 +1,29 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for an unusually large spike in successful authentication events. This can be due to password spraying, user enumeration or brute force activity.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high count of logon events", + "function": "high_non_zero_count", + "detector_index": 0 + } + ], + "influencers": [] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json new file mode 100644 index 000000000000000..7bbbc81b6de7ab5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_events_for_a_source_ip.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for an unusually large spike in successful authentication events events from a particular source IP address. This can be due to password spraying, user enumeration or brute force activity.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high count of auth events for a source IP", + "function": "high_non_zero_count", + "by_field_name": "source.ip", + "detector_index": 0 + } + ], + "influencers": [ + "source.ip", + "winlog.event_data.LogonType", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json new file mode 100644 index 000000000000000..4b7094e92c6ecf6 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_high_count_logon_fails.json @@ -0,0 +1,29 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for an unusually large spike in authentication failure events. This can be due to password spraying, user enumeration or brute force activity and may be a precursor to account takeover or credentialed access.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high count of logon fails", + "function": "high_non_zero_count", + "detector_index": 0 + } + ], + "influencers": [] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_hour_for_a_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_hour_for_a_user.json new file mode 100644 index 000000000000000..bb86d256e59df04 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_hour_for_a_user.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for a user logging in at a time of day that is unusual for the user. This can be due to credentialed access via a compromised account when the user and the threat actor are in different time zones. In addition, unauthorized user activity often takes place during non-business hours.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare hour for a user", + "function": "time_of_day", + "by_field_name": "user.name", + "detector_index": 0 + } + ], + "influencers": [ + "source.ip", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_source_ip_for_a_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_source_ip_for_a_user.json new file mode 100644 index 000000000000000..6f72e148fa38ed6 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_source_ip_for_a_user.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for a user logging in from an IP address that is unusual for the user. This can be due to credentialed access via a compromised account when the user and the threat actor are in different locations. An unusual source IP address for a username could also be due to lateral movement when a compromised account is used to pivot between hosts.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare source IP for a user", + "function": "rare", + "by_field_name": "source.ip", + "partition_field_name": "user.name", + "detector_index": 0 + } + ], + "influencers": [ + "source.ip", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_user.json new file mode 100644 index 000000000000000..5cb9c7112b29d3f --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/auth_rare_user.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Security: Authentication - looks for an unusual user name in the authentication logs. An unusual user name is one way of detecting credentialed access by means of a new or dormant user account. A user account that is normally inactive, because the user has left the organization, which becomes active, may be due to credentialed access using a compromised account password. Threat actors will sometimes also create new users as a means of persisting in a compromised web application.", + "groups": [ + "security", + "authentication" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "rare user", + "function": "rare", + "by_field_name": "user.name", + "detector_index": 0 + } + ], + "influencers": [ + "source.ip", + "user.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-security-auth" + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events.json new file mode 100644 index 000000000000000..eb81179e443637d --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_high_count_logon_events", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "success" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json new file mode 100644 index 000000000000000..dfed3ada1fe0bea --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_events_for_a_source_ip.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_high_count_logon_events_for_a_source_ip", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "success" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_fails.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_fails.json new file mode 100644 index 000000000000000..431c115b34d6047 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_high_count_logon_fails.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_high_count_logon_fails", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "failure" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_hour_for_a_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_hour_for_a_user.json new file mode 100644 index 000000000000000..377197231f28c10 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_hour_for_a_user.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_rare_hour_for_a_user", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "success" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_source_ip_for_a_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_source_ip_for_a_user.json new file mode 100644 index 000000000000000..dfa2ad7ab397c9a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_source_ip_for_a_user.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_rare_source_ip_for_a_user", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "success" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_user.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_user.json new file mode 100644 index 000000000000000..f7de5d3aee71a7a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/security_auth/ml/datafeed_auth_rare_user.json @@ -0,0 +1,26 @@ +{ + "job_id": "auth_rare_user", + "indices": [ + "auditbeat-*", + "logs-*", + "filebeat-*", + "winlogbeat-*" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + { + "term": { + "event.category": "authentication" + } + }, + { + "term": { + "event.outcome": "success" + } + } + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx index 8dac6234f19a865..e7199f6df2b1f55 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_modules.tsx @@ -17,6 +17,7 @@ export const mlModules: string[] = [ 'siem_packetbeat', 'siem_winlogbeat', 'siem_winlogbeat_auth', + 'security_auth', 'security_linux', 'security_network', 'security_windows', diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index 4fa79b915cc5dff..0a3e2dbed570bf1 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -30,6 +30,7 @@ const moduleIds = [ 'nginx_ecs', 'sample_data_ecommerce', 'sample_data_weblogs', + 'security_auth', 'security_linux', 'security_network', 'security_windows', diff --git a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts index 2181bea8b40407b..2742fbff294c0d3 100644 --- a/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/recognize_module.ts @@ -84,7 +84,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['siem_auditbeat', 'siem_auditbeat_auth'], + moduleIds: ['security_auth', 'siem_auditbeat', 'siem_auditbeat_auth'], }, }, { @@ -105,6 +105,7 @@ export default ({ getService }: FtrProviderContext) => { expected: { responseCode: 200, moduleIds: [ + 'security_auth', 'security_network', 'security_windows', 'siem_winlogbeat', @@ -148,7 +149,7 @@ export default ({ getService }: FtrProviderContext) => { user: USER.ML_POWERUSER, expected: { responseCode: 200, - moduleIds: ['security_linux', 'security_network', 'security_windows'], + moduleIds: ['security_auth', 'security_linux', 'security_network', 'security_windows'], }, }, { From 2dbf680e22b6dabcc642e727cf6d81afabd18ea3 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Mon, 14 Jun 2021 22:37:01 +0200 Subject: [PATCH 03/38] adds support for setting multiple variables to varSet (#100458) --- .../common/execution/execution.test.ts | 4 +-- .../specs/tests/var_set.test.ts | 36 +++++++++++++++++-- .../expression_functions/specs/var_set.ts | 10 ++++-- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 69687f75f309828..feff425cc48edd3 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -834,8 +834,8 @@ describe('Execution', () => { expect((chain[0].arguments.val[0] as ExpressionAstExpression).chain[0].debug!.args).toEqual( { - name: 'foo', - value: 5, + name: ['foo'], + value: [5], } ); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts index 0a9f022ce89cad7..cdcae61215fa423 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/var_set.test.ts @@ -9,6 +9,8 @@ import { functionWrapper } from './utils'; import { variableSet } from '../var_set'; import { ExecutionContext } from '../../../execution/types'; +import { createUnitTestExecutor } from '../../../test_helpers'; +import { first } from 'rxjs/operators'; describe('expression_functions', () => { describe('var_set', () => { @@ -32,21 +34,49 @@ describe('expression_functions', () => { }); it('updates a variable', () => { - const actual = fn(input, { name: 'test', value: 2 }, context); + const actual = fn(input, { name: ['test'], value: [2] }, context); expect(variables.test).toEqual(2); expect(actual).toEqual(input); }); it('sets a new variable', () => { - const actual = fn(input, { name: 'new', value: 3 }, context); + const actual = fn(input, { name: ['new'], value: [3] }, context); expect(variables.new).toEqual(3); expect(actual).toEqual(input); }); it('stores context if value is not set', () => { - const actual = fn(input, { name: 'test' }, context); + const actual = fn(input, { name: ['test'], value: [] }, context); expect(variables.test).toEqual(input); expect(actual).toEqual(input); }); + + it('sets multiple variables', () => { + const actual = fn(input, { name: ['new1', 'new2', 'new3'], value: [1, , 3] }, context); + expect(variables.new1).toEqual(1); + expect(variables.new2).toEqual(input); + expect(variables.new3).toEqual(3); + expect(actual).toEqual(input); + }); + + describe('running function thru executor', () => { + const executor = createUnitTestExecutor(); + executor.registerFunction(variableSet); + + it('sets the variables', async () => { + const vars = {}; + const result = await executor + .run('var_set name=test1 name=test2 value=1', 2, { variables: vars }) + .pipe(first()) + .toPromise(); + + expect(result).toEqual(2); + + expect(vars).toEqual({ + test1: 1, + test2: 2, + }); + }); + }); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/var_set.ts b/src/plugins/expressions/common/expression_functions/specs/var_set.ts index 490c7781a01a1e6..f3ac6a2ab80d4a8 100644 --- a/src/plugins/expressions/common/expression_functions/specs/var_set.ts +++ b/src/plugins/expressions/common/expression_functions/specs/var_set.ts @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; interface Arguments { - name: string; - value?: any; + name: string[]; + value: any[]; } export type ExpressionFunctionVarSet = ExpressionFunctionDefinition< @@ -31,12 +31,14 @@ export const variableSet: ExpressionFunctionVarSet = { types: ['string'], aliases: ['_'], required: true, + multi: true, help: i18n.translate('expressions.functions.varset.name.help', { defaultMessage: 'Specify the name of the variable.', }), }, value: { aliases: ['val'], + multi: true, help: i18n.translate('expressions.functions.varset.val.help', { defaultMessage: 'Specify the value for the variable. When unspecified, the input context is used.', @@ -45,7 +47,9 @@ export const variableSet: ExpressionFunctionVarSet = { }, fn(input, args, context) { const variables: Record = context.variables; - variables[args.name] = args.value === undefined ? input : args.value; + args.name.forEach((name, i) => { + variables[name] = args.value[i] === undefined ? input : args.value[i]; + }); return input; }, }; From 7cee2eefdc820123b6350ec5d33fd723fad7bd79 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 14 Jun 2021 14:01:22 -0700 Subject: [PATCH 04/38] [kbnArchiver] handle archives which have \r\n (#102118) Co-authored-by: spalger --- .../import_export/parse_archive.test.ts | 63 +++++++++++++++++++ .../kbn_client/import_export/parse_archive.ts | 22 +++++++ .../kbn_client/kbn_client_import_export.ts | 15 +---- 3 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts create mode 100644 packages/kbn-test/src/kbn_client/import_export/parse_archive.ts diff --git a/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts b/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts new file mode 100644 index 000000000000000..25651a0dd21902f --- /dev/null +++ b/packages/kbn-test/src/kbn_client/import_export/parse_archive.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { parseArchive } from './parse_archive'; + +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), +})); + +const mockReadFile = jest.requireMock('fs/promises').readFile; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('parses archives with \\n', async () => { + mockReadFile.mockResolvedValue( + `{ + "foo": "abc" + }\n\n{ + "foo": "xyz" + }` + ); + + const archive = await parseArchive('mock'); + expect(archive).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "abc", + }, + Object { + "foo": "xyz", + }, + ] + `); +}); + +it('parses archives with \\r\\n', async () => { + mockReadFile.mockResolvedValue( + `{ + "foo": "123" + }\r\n\r\n{ + "foo": "456" + }` + ); + + const archive = await parseArchive('mock'); + expect(archive).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "123", + }, + Object { + "foo": "456", + }, + ] + `); +}); diff --git a/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts b/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts new file mode 100644 index 000000000000000..b6b85ba521525bf --- /dev/null +++ b/packages/kbn-test/src/kbn_client/import_export/parse_archive.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs/promises'; + +export interface SavedObject { + id: string; + type: string; + [key: string]: unknown; +} + +export async function parseArchive(path: string): Promise { + return (await Fs.readFile(path, 'utf-8')) + .split(/\r?\n\r?\n/) + .filter((line) => !!line) + .map((line) => JSON.parse(line)); +} diff --git a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts index 88953cdbaed7c98..4adae7d1cd031e8 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_import_export.ts @@ -16,25 +16,12 @@ import { ToolingLog, isAxiosResponseError, createFailError, REPO_ROOT } from '@k import { KbnClientRequester, uriencode, ReqOptions } from './kbn_client_requester'; import { KbnClientSavedObjects } from './kbn_client_saved_objects'; +import { parseArchive } from './import_export/parse_archive'; interface ImportApiResponse { success: boolean; [key: string]: unknown; } - -interface SavedObject { - id: string; - type: string; - [key: string]: unknown; -} - -async function parseArchive(path: string): Promise { - return (await Fs.readFile(path, 'utf-8')) - .split('\n\n') - .filter((line) => !!line) - .map((line) => JSON.parse(line)); -} - export class KbnClientImportExport { constructor( public readonly log: ToolingLog, From 6e0aed79c3375f426e8e7244f2a60741278fcc38 Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 14 Jun 2021 15:51:33 -0600 Subject: [PATCH 05/38] [Maps] Fix geo alerts handling of multi-fields (#100348) --- .../entity_by_expression.test.tsx.snap | 171 ++++++++++++++++++ .../expressions/entity_by_expression.test.tsx | 94 ++++++++++ .../expressions/entity_by_expression.tsx | 19 +- 3 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap create mode 100644 x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.test.tsx diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap new file mode 100644 index 000000000000000..d9dd6ec4a0be53e --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/__snapshots__/entity_by_expression.test.tsx.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render entity by expression with aggregatable field options for entity 1`] = ` + + + + + } + value="FlightNum" + > + + } + closePopover={[Function]} + display="block" + hasArrow={true} + id="popoverForExpression" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + zIndex={8000} + > +
+
+ + + +
+
+
+
+
+`; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.test.tsx new file mode 100644 index 000000000000000..31b89873922c9c6 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { EntityByExpression, getValidIndexPatternFields } from './entity_by_expression'; + +const defaultProps = { + errors: { + index: [], + indexId: [], + geoField: [], + entity: [], + dateField: [], + boundaryType: [], + boundaryIndexTitle: [], + boundaryIndexId: [], + boundaryGeoField: [], + name: ['Name is required.'], + interval: [], + alertTypeId: [], + actionConnectors: [], + }, + entity: 'FlightNum', + setAlertParamsEntity: (arg: string) => {}, + indexFields: [ + { + count: 0, + name: 'DestLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'FlightNum', + type: 'string', + esTypes: ['keyword'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'OriginLocation', + type: 'geo_point', + esTypes: ['geo_point'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + count: 0, + name: 'timestamp', + type: 'date', + esTypes: ['date'], + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ], + isInvalid: false, +}; + +test('should render entity by expression with aggregatable field options for entity', async () => { + const component = mount(); + expect(component).toMatchSnapshot(); +}); +// + +test('should only use valid index fields', async () => { + // Only the string index field should match + const indexFields = getValidIndexPatternFields(defaultProps.indexFields); + expect(indexFields.length).toEqual(1); + + // Set all agg fields to false, invalidating them for use + const invalidIndexFields = defaultProps.indexFields.map((field) => ({ + ...field, + aggregatable: false, + })); + + const noIndexFields = getValidIndexPatternFields(invalidIndexFields); + expect(noIndexFields.length).toEqual(0); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx index 2df6439ad56f050..a194bd40d9931f3 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/query_builder/expressions/entity_by_expression.tsx @@ -22,6 +22,16 @@ interface Props { isInvalid: boolean; } +const ENTITY_TYPES = ['string', 'number', 'ip']; +export function getValidIndexPatternFields(fields: IFieldType[]): IFieldType[] { + return fields.filter((field) => { + const isSpecifiedSupportedField = ENTITY_TYPES.includes(field.type); + const hasLeadingUnderscore = field.name.startsWith('_'); + const isAggregatable = !!field.aggregatable; + return isSpecifiedSupportedField && isAggregatable && !hasLeadingUnderscore; + }); +} + export const EntityByExpression: FunctionComponent = ({ errors, entity, @@ -29,9 +39,6 @@ export const EntityByExpression: FunctionComponent = ({ indexFields, isInvalid, }) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const ENTITY_TYPES = ['string', 'number', 'ip']; - const usePrevious = (value: T): T | undefined => { const ref = useRef(); useEffect(() => { @@ -48,14 +55,12 @@ export const EntityByExpression: FunctionComponent = ({ }); useEffect(() => { if (!_.isEqual(oldIndexFields, indexFields)) { - fields.current.indexFields = indexFields.filter( - (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') - ); + fields.current.indexFields = getValidIndexPatternFields(indexFields); if (!entity && fields.current.indexFields.length) { setAlertParamsEntity(fields.current.indexFields[0].name); } } - }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); + }, [indexFields, oldIndexFields, setAlertParamsEntity, entity]); const indexPopover = ( From 55a0dbbc097c1459d0e505291640cbc8c42134dd Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 14 Jun 2021 15:24:24 -0700 Subject: [PATCH 06/38] Discourage use of legacy index templates (#101533) * Hide legacy index templates table if the user doesn't have any. * Render deprecation warning above legacy index templates table and in legacy index template wizard. * Update index template doc link to point to the new docs. --- .../src/jest/utils/router_helpers.tsx | 37 ++++++-- .../kbn-test/src/jest/utils/testbed/types.ts | 5 +- .../public/doc_links/doc_links_service.ts | 2 +- .../home/index_templates_tab.test.ts | 22 ++++- .../template_create.helpers.ts | 30 ++++--- .../template_create.test.tsx | 32 ++++++- .../template_form.helpers.ts | 1 + .../components/index_templates/index.ts | 10 ++- .../legacy_index_template_deprecation.tsx | 84 +++++++++++++++++++ .../template_form/template_form.tsx | 30 +++++-- .../home/template_list/components/index.ts | 2 +- .../home/template_list/template_list.tsx | 25 ++++-- .../template_clone/template_clone.tsx | 2 + .../template_create/template_create.tsx | 4 +- .../sections/template_edit/template_edit.tsx | 4 +- 15 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 x-pack/plugins/index_management/public/application/components/index_templates/legacy_index_template_deprecation.tsx diff --git a/packages/kbn-test/src/jest/utils/router_helpers.tsx b/packages/kbn-test/src/jest/utils/router_helpers.tsx index e2245440274d190..85ef27488a4ce95 100644 --- a/packages/kbn-test/src/jest/utils/router_helpers.tsx +++ b/packages/kbn-test/src/jest/utils/router_helpers.tsx @@ -8,18 +8,39 @@ import React, { Component, ComponentType } from 'react'; import { MemoryRouter, Route, withRouter } from 'react-router-dom'; -import * as H from 'history'; +import { History, LocationDescriptor } from 'history'; -export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex: number = 0) => ( - WrappedComponent: ComponentType -) => (props: any) => ( +const stringifyPath = (path: LocationDescriptor): string => { + if (typeof path === 'string') { + return path; + } + + return path.pathname || '/'; +}; + +const locationDescriptorToRoutePath = ( + paths: LocationDescriptor | LocationDescriptor[] +): string | string[] => { + if (Array.isArray(paths)) { + return paths.map((path: LocationDescriptor) => { + return stringifyPath(path); + }); + } + + return stringifyPath(paths); +}; + +export const WithMemoryRouter = ( + initialEntries: LocationDescriptor[] = ['/'], + initialIndex: number = 0 +) => (WrappedComponent: ComponentType) => (props: any) => ( ); export const WithRoute = ( - componentRoutePath: string | string[] = '/', + componentRoutePath: LocationDescriptor | LocationDescriptor[] = ['/'], onRouter = (router: any) => {} ) => (WrappedComponent: ComponentType) => { // Create a class component that will catch the router @@ -40,16 +61,16 @@ export const WithRoute = ( return (props: any) => ( } /> ); }; interface Router { - history: Partial; + history: Partial; route: { - location: H.Location; + location: LocationDescriptor; }; } diff --git a/packages/kbn-test/src/jest/utils/testbed/types.ts b/packages/kbn-test/src/jest/utils/testbed/types.ts index fdc000215c4f197..bba504951c0bc6e 100644 --- a/packages/kbn-test/src/jest/utils/testbed/types.ts +++ b/packages/kbn-test/src/jest/utils/testbed/types.ts @@ -8,6 +8,7 @@ import { Store } from 'redux'; import { ReactWrapper } from 'enzyme'; +import { LocationDescriptor } from 'history'; export type SetupFunc = (props?: any) => TestBed | Promise>; @@ -161,11 +162,11 @@ export interface MemoryRouterConfig { /** Flag to add or not the `MemoryRouter`. If set to `false`, there won't be any router and the component won't be wrapped on a ``. */ wrapComponent?: boolean; /** The React Router **initial entries** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ - initialEntries?: string[]; + initialEntries?: LocationDescriptor[]; /** The React Router **initial index** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */ initialIndex?: number; /** The route **path** for the mounted component (defaults to `"/"`) */ - componentRoutePath?: string | string[]; + componentRoutePath?: LocationDescriptor | LocationDescriptor[]; /** A callBack that will be called with the React Router instance once mounted */ onRouter?: (router: any) => void; } diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 53428edf4b345f2..06277d9351922c2 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -142,7 +142,7 @@ export class DocLinksService { dataStreams: `${ELASTICSEARCH_DOCS}data-streams.html`, indexModules: `${ELASTICSEARCH_DOCS}index-modules.html`, indexSettings: `${ELASTICSEARCH_DOCS}index-modules.html#index-modules-settings`, - indexTemplates: `${ELASTICSEARCH_DOCS}indices-templates.html`, + indexTemplates: `${ELASTICSEARCH_DOCS}index-templates.html`, mapping: `${ELASTICSEARCH_DOCS}mapping.html`, mappingAnalyzer: `${ELASTICSEARCH_DOCS}analyzer.html`, mappingCoerce: `${ELASTICSEARCH_DOCS}coerce.html`, diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index e43f147a65800ff..bf1a78e3cfe90fa 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -31,7 +31,7 @@ describe('Index Templates tab', () => { server.restore(); }); - describe('when there are no index templates', () => { + describe('when there are no index templates of either kind', () => { test('should display an empty prompt', async () => { httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] }); @@ -46,6 +46,26 @@ describe('Index Templates tab', () => { }); }); + describe('when there are composable index templates but no legacy index templates', () => { + test('only the composable index templates table is visible', async () => { + httpRequestsMockHelpers.setLoadTemplatesResponse({ + templates: [fixtures.getComposableTemplate()], + legacyTemplates: [], + }); + + await act(async () => { + testBed = await setup(); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('sectionLoading')).toBe(false); + expect(exists('emptyPrompt')).toBe(false); + expect(exists('templateTable')).toBe(true); + expect(exists('legacyTemplateTable')).toBe(false); + }); + }); + describe('when there are index templates', () => { // Add a default loadIndexTemplate response httpRequestsMockHelpers.setLoadTemplateResponse(fixtures.getTemplate()); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts index 199ace6048bdec8..7d3b34a6b823877 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.helpers.ts @@ -11,17 +11,23 @@ import { WithAppDependencies } from '../helpers'; import { formSetup, TestSubjects } from './template_form.helpers'; -const testBedConfig: TestBedConfig = { - memoryRouter: { - initialEntries: [`/create_template`], - componentRoutePath: `/create_template`, - }, - doMountAsync: true, -}; +export const setup: any = (isLegacy: boolean = false) => { + const route = isLegacy + ? { pathname: '/create_template', search: '?legacy=true' } + : { pathname: '/create_template' }; + + const testBedConfig: TestBedConfig = { + memoryRouter: { + initialEntries: [route], + componentRoutePath: route, + }, + doMountAsync: true, + }; -const initTestBed = registerTestBed( - WithAppDependencies(TemplateCreate), - testBedConfig -); + const initTestBed = registerTestBed( + WithAppDependencies(TemplateCreate), + testBedConfig + ); -export const setup: any = formSetup.bind(null, initTestBed); + return formSetup.call(null, initTestBed); +}; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 9f435fac8b3471c..77ce172f3e0dbaa 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -101,7 +101,7 @@ describe('', () => { (window as any)['__react-beautiful-dnd-disable-dev-warnings'] = false; }); - describe('on component mount', () => { + describe('composable index template', () => { beforeEach(async () => { await act(async () => { testBed = await setup(); @@ -115,6 +115,11 @@ describe('', () => { expect(find('pageTitle').text()).toEqual('Create template'); }); + test('renders no deprecation warning', async () => { + const { exists } = testBed; + expect(exists('legacyIndexTemplateDeprecationWarning')).toBe(false); + }); + test('should not let the user go to the next step with invalid fields', async () => { const { find, actions, component } = testBed; @@ -129,6 +134,26 @@ describe('', () => { }); }); + describe('legacy index template', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(true); + }); + }); + + test('should set the correct page title', () => { + const { exists, find } = testBed; + + expect(exists('pageTitle')).toBe(true); + expect(find('pageTitle').text()).toEqual('Create legacy template'); + }); + + test('renders deprecation warning', async () => { + const { exists } = testBed; + expect(exists('legacyIndexTemplateDeprecationWarning')).toBe(true); + }); + }); + describe('form validation', () => { beforeEach(async () => { await act(async () => { @@ -150,6 +175,11 @@ describe('', () => { expect(find('stepTitle').text()).toEqual('Component templates (optional)'); }); + it(`doesn't render the deprecated legacy index template warning`, () => { + const { exists } = testBed; + expect(exists('legacyIndexTemplateDeprecationWarning')).toBe(false); + }); + it('should list the available component templates', () => { const { actions: { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 355bbc12f94fc89..01aeba31770db99 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -306,6 +306,7 @@ export type TestSubjects = | 'indexPatternsField' | 'indexPatternsWarning' | 'indexPatternsWarningDescription' + | 'legacyIndexTemplateDeprecationWarning' | 'mappingsEditorFieldEdit' | 'mockCodeEditor' | 'mockComboBox' diff --git a/x-pack/plugins/index_management/public/application/components/index_templates/index.ts b/x-pack/plugins/index_management/public/application/components/index_templates/index.ts index a9131bab70551e7..d460175543ac532 100644 --- a/x-pack/plugins/index_management/public/application/components/index_templates/index.ts +++ b/x-pack/plugins/index_management/public/application/components/index_templates/index.ts @@ -5,4 +5,12 @@ * 2.0. */ -export * from './simulate_template'; +export { + SimulateTemplateFlyoutContent, + simulateTemplateFlyoutProps, + SimulateTemplateProps, + SimulateTemplate, + SimulateTemplateFilters, +} from './simulate_template'; + +export { LegacyIndexTemplatesDeprecation } from './legacy_index_template_deprecation'; diff --git a/x-pack/plugins/index_management/public/application/components/index_templates/legacy_index_template_deprecation.tsx b/x-pack/plugins/index_management/public/application/components/index_templates/legacy_index_template_deprecation.tsx new file mode 100644 index 000000000000000..6fbea1760f3a42f --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/index_templates/legacy_index_template_deprecation.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; +import { reactRouterNavigate } from '../../../shared_imports'; +import { documentationService } from '../../services/documentation'; + +interface Props { + history?: ScopedHistory; + showCta?: boolean; +} + +export const LegacyIndexTemplatesDeprecation: React.FunctionComponent = ({ + history, + showCta, +}) => { + return ( + + {showCta && history && ( +

+ + + + ), + learnMoreLink: ( + + {i18n.translate( + 'xpack.idxMgmt.home.legacyIndexTemplatesDeprecation.ctaLearnMoreLinkText', + { + defaultMessage: 'learn more.', + } + )} + + ), + }} + /> +

+ )} + + {!showCta && ( + + {i18n.translate('xpack.idxMgmt.home.legacyIndexTemplatesDeprecation.learnMoreLinkText', { + defaultMessage: 'Learn more.', + })} + + )} +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index ef9cde30907f027..54160141827d0a8 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -9,24 +9,26 @@ import React, { useState, useCallback, useRef } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer, EuiButton } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; import { TemplateDeserialized } from '../../../../common'; import { serializers, Forms, GlobalFlyout } from '../../../shared_imports'; +import { + CommonWizardSteps, + StepSettingsContainer, + StepMappingsContainer, + StepAliasesContainer, +} from '../shared'; +import { documentationService } from '../../services/documentation'; import { SectionError } from '../section_error'; import { SimulateTemplateFlyoutContent, SimulateTemplateProps, simulateTemplateFlyoutProps, SimulateTemplateFilters, + LegacyIndexTemplatesDeprecation, } from '../index_templates'; import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps'; -import { - CommonWizardSteps, - StepSettingsContainer, - StepMappingsContainer, - StepAliasesContainer, -} from '../shared'; -import { documentationService } from '../../services/documentation'; const { stripEmptyFields } = serializers; const { FormWizard, FormWizardStep } = Forms; @@ -38,6 +40,7 @@ interface Props { clearSaveError: () => void; isSaving: boolean; saveError: any; + history?: ScopedHistory; isLegacy?: boolean; defaultValue?: TemplateDeserialized; isEditing?: boolean; @@ -98,6 +101,7 @@ export const TemplateForm = ({ saveError, clearSaveError, onSave, + history, }: Props) => { const [wizardContent, setWizardContent] = useState | null>(null); const { addContent: addContentToGlobalFlyout, closeFlyout } = useGlobalFlyout(); @@ -283,12 +287,20 @@ export const TemplateForm = ({ ); }; + const isLegacyIndexTemplate = indexTemplate._kbnMeta.isLegacy === true; + return ( <> {/* Form header */} {title} - + + + {isLegacyIndexTemplate && ( + + )} + + defaultValue={wizardDefaultValue} @@ -311,7 +323,7 @@ export const TemplateForm = ({ /> - {indexTemplate._kbnMeta.isLegacy !== true && ( + {!isLegacyIndexTemplate && ( diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts index b820bf559fb74c1..8b756be535ed234 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './template_type_indicator'; +export { TemplateTypeIndicator } from './template_type_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx index ecd41455d3249b7..b8b5a8e3c7d1a42 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx @@ -24,7 +24,13 @@ import { import { UIM_TEMPLATE_LIST_LOAD } from '../../../../../common/constants'; import { TemplateListItem } from '../../../../../common'; -import { SectionError, SectionLoading, Error } from '../../../components'; +import { attemptToURIDecode } from '../../../../shared_imports'; +import { + SectionError, + SectionLoading, + Error, + LegacyIndexTemplatesDeprecation, +} from '../../../components'; import { useLoadIndexTemplates } from '../../../services/api'; import { documentationService } from '../../../services/documentation'; import { useServices } from '../../../app_context'; @@ -34,11 +40,10 @@ import { getTemplateCloneLink, } from '../../../services/routing'; import { getIsLegacyFromQueryParams } from '../../../lib/index_templates'; +import { FilterListButton, Filters } from '../components'; import { TemplateTable } from './template_table'; import { TemplateDetails } from './template_details'; import { LegacyTemplateTable } from './legacy_templates/template_table'; -import { FilterListButton, Filters } from '../components'; -import { attemptToURIDecode } from '../../../../shared_imports'; type FilterName = 'managed' | 'cloudManaged' | 'system'; interface MatchParams { @@ -130,7 +135,7 @@ export const TemplateList: React.FunctionComponent - + + + + + + + 0 && renderLegacyTemplatesTable()} ); } diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index 37df44d175771c7..36bff298e345ba9 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -9,6 +9,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; import { TemplateDeserialized } from '../../../../common'; import { TemplateForm, SectionLoading, SectionError, Error } from '../../components'; @@ -114,6 +115,7 @@ export const TemplateClone: React.FunctionComponent ); } diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index 32b6ce7181bfd51..310807aeef38fdf 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -11,10 +11,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; import { parse } from 'query-string'; +import { ScopedHistory } from 'kibana/public'; +import { TemplateDeserialized } from '../../../../common'; import { TemplateForm } from '../../components'; import { breadcrumbService } from '../../services/breadcrumbs'; -import { TemplateDeserialized } from '../../../../common'; import { saveTemplate } from '../../services/api'; import { getTemplateDetailsLink } from '../../services/routing'; @@ -76,6 +77,7 @@ export const TemplateCreate: React.FunctionComponent = ({ h saveError={saveError} clearSaveError={clearSaveError} isLegacy={isLegacy} + history={history as ScopedHistory} /> diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index 716f85e5ff1c47a..f4ffe97931a2409 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -9,14 +9,15 @@ import React, { useEffect, useState, Fragment } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiPageBody, EuiPageContent, EuiTitle, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { ScopedHistory } from 'kibana/public'; import { TemplateDeserialized } from '../../../../common'; +import { attemptToURIDecode } from '../../../shared_imports'; import { breadcrumbService } from '../../services/breadcrumbs'; import { useLoadIndexTemplate, updateTemplate } from '../../services/api'; import { getTemplateDetailsLink } from '../../services/routing'; import { SectionLoading, SectionError, TemplateForm, Error } from '../../components'; import { getIsLegacyFromQueryParams } from '../../lib/index_templates'; -import { attemptToURIDecode } from '../../../shared_imports'; interface MatchParams { name: string; @@ -154,6 +155,7 @@ export const TemplateEdit: React.FunctionComponent ); From 61602fe2beed8e7afde3ded7356cd9b9100c8f82 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 14 Jun 2021 20:40:43 -0400 Subject: [PATCH 07/38] [Alerting][Docs] Reformatting rule types docs (#101420) * Reformatting rule types docs * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Restructure geo rule page * Apply suggestions from code review Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * PR fixes Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- .../alerting/domain-specific-rules.asciidoc | 20 ------- docs/user/alerting/index.asciidoc | 3 +- docs/user/alerting/rule-types.asciidoc | 56 +++++++++++++++++++ .../es-query.asciidoc | 0 .../geo-rule-types.asciidoc | 33 ++++------- .../index-threshold.asciidoc | 0 docs/user/alerting/stack-rules.asciidoc | 27 --------- 7 files changed, 69 insertions(+), 70 deletions(-) delete mode 100644 docs/user/alerting/domain-specific-rules.asciidoc create mode 100644 docs/user/alerting/rule-types.asciidoc rename docs/user/alerting/{stack-rules => rule-types}/es-query.asciidoc (100%) rename docs/user/alerting/{map-rules => rule-types}/geo-rule-types.asciidoc (74%) rename docs/user/alerting/{stack-rules => rule-types}/index-threshold.asciidoc (100%) delete mode 100644 docs/user/alerting/stack-rules.asciidoc diff --git a/docs/user/alerting/domain-specific-rules.asciidoc b/docs/user/alerting/domain-specific-rules.asciidoc deleted file mode 100644 index f509f9e5288234f..000000000000000 --- a/docs/user/alerting/domain-specific-rules.asciidoc +++ /dev/null @@ -1,20 +0,0 @@ -[role="xpack"] -[[domain-specific-rules]] -== Domain-specific rules - -For domain-specific rules, refer to the documentation for that app. -{kib} supports these rules: - -* {observability-guide}/create-alerts.html[Observability rules] -* {security-guide}/prebuilt-rules.html[Security rules] -* <> -* {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] - -[NOTE] -============================================== -Some rule types are subscription features, while others are free features. -For a comparison of the Elastic subscription levels, -see {subscriptions}[the subscription page]. -============================================== - -include::map-rules/geo-rule-types.asciidoc[] diff --git a/docs/user/alerting/index.asciidoc b/docs/user/alerting/index.asciidoc index 68cf3ee070b0893..9ab6a2dc46ebf2d 100644 --- a/docs/user/alerting/index.asciidoc +++ b/docs/user/alerting/index.asciidoc @@ -3,6 +3,5 @@ include::alerting-setup.asciidoc[] include::create-and-manage-rules.asciidoc[] include::defining-rules.asciidoc[] include::rule-management.asciidoc[] -include::stack-rules.asciidoc[] -include::domain-specific-rules.asciidoc[] +include::rule-types.asciidoc[] include::alerting-troubleshooting.asciidoc[] diff --git a/docs/user/alerting/rule-types.asciidoc b/docs/user/alerting/rule-types.asciidoc new file mode 100644 index 000000000000000..bb840014fe80fb4 --- /dev/null +++ b/docs/user/alerting/rule-types.asciidoc @@ -0,0 +1,56 @@ +[role="xpack"] +[[rule-types]] +== Rule types + +A rule is a set of <>, <>, and <> that enable notifications. {kib} provides two types of rules: rules specific to the Elastic Stack and rules specific to a domain. + +[NOTE] +============================================== +Some rule types are subscription features, while others are free features. +For a comparison of the Elastic subscription levels, +see {subscriptions}[the subscription page]. +============================================== + +[float] +[[stack-rules]] +=== Stack rules + +<> are built into {kib}. To access the *Stack Rules* feature and create and edit rules, users require the `all` privilege. See <> for more information. + +[cols="2*<"] +|=== + +| <> +| Aggregate field values from documents using {es} queries, compare them to threshold values, and schedule actions to run when the thresholds are met. + +| <> +| Run a user-configured {es} query, compare the number of matches to a configured threshold, and schedule actions to run when the threshold condition is met. + +|=== + +[float] +[[domain-specific-rules]] +=== Domain rules + +Domain rules are registered by *Observability*, *Security*, <> and <>. + +[cols="2*<"] +|=== + +| {observability-guide}/create-alerts.html[Observability rules] +| Detect complex conditions in the *Logs*, *Metrics*, and *Uptime* apps. + +| {security-guide}/prebuilt-rules.html[Security rules] +| Detect suspicous source events with pre-built or custom rules and create alerts when a rule’s conditions are met. + +| <> +| Run an {es} query to determine if any documents are currently contained in any boundaries from a specified boundary index and generate alerts when a rule's conditions are met. + +| {ml-docs}/ml-configuring-alerts.html[{ml-cap} rules] beta:[] +| Run scheduled checks on an anomaly detection job to detect anomalies with certain conditions. If an anomaly meets the conditions, an alert is created and the associated action is triggered. + +|=== + +include::rule-types/index-threshold.asciidoc[] +include::rule-types/es-query.asciidoc[] +include::rule-types/geo-rule-types.asciidoc[] diff --git a/docs/user/alerting/stack-rules/es-query.asciidoc b/docs/user/alerting/rule-types/es-query.asciidoc similarity index 100% rename from docs/user/alerting/stack-rules/es-query.asciidoc rename to docs/user/alerting/rule-types/es-query.asciidoc diff --git a/docs/user/alerting/map-rules/geo-rule-types.asciidoc b/docs/user/alerting/rule-types/geo-rule-types.asciidoc similarity index 74% rename from docs/user/alerting/map-rules/geo-rule-types.asciidoc rename to docs/user/alerting/rule-types/geo-rule-types.asciidoc index eee7b592522054a..244cf90c855a7e1 100644 --- a/docs/user/alerting/map-rules/geo-rule-types.asciidoc +++ b/docs/user/alerting/rule-types/geo-rule-types.asciidoc @@ -1,16 +1,14 @@ [role="xpack"] [[geo-alerting]] -=== Geo rule type +=== Tracking containment -Alerting now includes one additional stack rule: <>. - -As with other stack rules, you need `all` access to the *Stack Rules* feature -to be able to create and edit a geo rule. -See <> for more information on configuring roles that provide access to this feature. +<> offers the Tracking containment rule type which runs an {es} query over indices to determine whether any +documents are currently contained within any boundaries from the specified boundary index. +In the event that an entity is contained within a boundary, an alert may be generated. [float] -==== Geo alerting requirements -To create a *Tracking containment* rule, the following requirements must be present: +==== Requirements +To create a Tracking containment rule, the following requirements must be present: - *Tracks index or index pattern*: An index containing a `geo_point` field, `date` field, and some form of entity identifier. An entity identifier is a `keyword` or `number` @@ -29,22 +27,12 @@ than the current time minus the amount of the interval. If data older than `now - ` is ingested, it won't trigger a rule. [float] -==== Creating a geo rule -Click the *Create* button in the <>. -Complete the <>. - -[role="screenshot"] -image::user/alerting/images/alert-types-tracking-select.png[Choosing a tracking rule type] +==== Create the rule -[float] -[[rule-type-tracking-containment]] -==== Tracking containment -The Tracking containment rule type runs an {es} query over indices, determining if any -documents are currently contained within any boundaries from the specified boundary index. -In the event that an entity is contained within a boundary, an alert may be generated. +Fill in the <>, then select Tracking containment. [float] -===== Defining the conditions +==== Define the conditions Tracking containment rules have 3 clauses that define the condition to detect, as well as 2 Kuery bars used to provide additional filtering context for each of the indices. @@ -61,6 +49,9 @@ Index (Boundary):: This clause requires an *index or index pattern*, a *`geo_sha identifying boundaries, and an optional *Human-readable boundary name* for better alerting messages. +[float] +==== Add action + Conditions for how a rule is tracked can be specified uniquely for each individual action. A rule can be triggered either when a containment condition is met or when an entity is no longer contained. diff --git a/docs/user/alerting/stack-rules/index-threshold.asciidoc b/docs/user/alerting/rule-types/index-threshold.asciidoc similarity index 100% rename from docs/user/alerting/stack-rules/index-threshold.asciidoc rename to docs/user/alerting/rule-types/index-threshold.asciidoc diff --git a/docs/user/alerting/stack-rules.asciidoc b/docs/user/alerting/stack-rules.asciidoc deleted file mode 100644 index 483834c78806e23..000000000000000 --- a/docs/user/alerting/stack-rules.asciidoc +++ /dev/null @@ -1,27 +0,0 @@ -[role="xpack"] -[[stack-rules]] -== Stack rule types - -Kibana provides two types of rules: - -* Stack rules, which are built into {kib} -* <>, which are registered by {kib} apps. - -{kib} provides two stack rules: - -* <> -* <> - -Users require the `all` privilege to access the *Stack Rules* feature and create and edit rules. -See <> for more information. - -[NOTE] -============================================== -Some rule types are subscription features, while others are free features. -For a comparison of the Elastic subscription levels, -see {subscriptions}[the subscription page]. -============================================== - - -include::stack-rules/index-threshold.asciidoc[] -include::stack-rules/es-query.asciidoc[] From 6351d51f6d222e9847722feebdfd946a57ed12da Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 15 Jun 2021 09:29:59 +0300 Subject: [PATCH 08/38] [Tagcloud] Replaces current implementation with elastic-charts (#100017) * WIP - Replace tagcloud with es-charts wordcloud * Cleanup and add unit tests * Fix interpreter test * Update all tagcloud snapshots * Partial fix tagcloud test * Fix some other functional tests, add migration script, update sample data * Replace getColor with getCategorixalColor * Fix functional test * Apply clickhandler event for filtering by clicking the word * Fix weight calculation * Add a unit test and fix functional * Change the cursor to pointer Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 1 - .../data_sets/ecommerce/saved_objects.ts | 2 +- .../data_sets/flights/saved_objects.ts | 2 +- .../__snapshots__/tag_cloud_fn.test.ts.snap | 11 + .../public/__snapshots__/to_ast.test.ts.snap | 3 + .../__snapshots__/tag_cloud.test.js.snap | 3 - .../tag_cloud_visualization.test.js.snap | 7 - .../public/components/feedback_message.js | 51 -- .../components/get_tag_cloud_options.tsx | 17 + .../public/components/label.js | 27 - .../public/components/tag_cloud.js | 409 -------------- .../public/components/tag_cloud.scss | 16 +- .../public/components/tag_cloud.test.js | 507 ------------------ .../components/tag_cloud_chart.test.tsx | 150 ++++++ .../public/components/tag_cloud_chart.tsx | 235 ++++++-- .../public/components/tag_cloud_options.tsx | 39 +- .../components/tag_cloud_visualization.js | 155 ------ .../tag_cloud_visualization.test.js | 128 ----- .../vis_type_tagcloud/public/plugin.ts | 12 +- .../public/tag_cloud_fn.test.ts | 1 + .../vis_type_tagcloud/public/tag_cloud_fn.ts | 31 +- .../public/tag_cloud_type.ts | 15 +- .../public/tag_cloud_vis_renderer.tsx | 23 +- .../vis_type_tagcloud/public/to_ast.test.ts | 5 + .../vis_type_tagcloud/public/to_ast.ts | 3 +- src/plugins/vis_type_tagcloud/public/types.ts | 28 +- .../visualize_embeddable_factory.ts | 11 +- .../visualization_common_migrations.ts | 22 + ...ualization_saved_object_migrations.test.ts | 41 ++ .../visualization_saved_object_migrations.ts | 26 +- test/functional/apps/visualize/_tag_cloud.ts | 10 +- .../functional/page_objects/tag_cloud_page.ts | 13 +- .../services/dashboard/expectations.ts | 5 +- .../snapshots/baseline/partial_test_1.json | 2 +- .../snapshots/baseline/tagcloud_all_data.json | 2 +- .../snapshots/baseline/tagcloud_fontsize.json | 2 +- .../baseline/tagcloud_invalid_data.json | 2 +- .../baseline/tagcloud_metric_data.json | 2 +- .../snapshots/baseline/tagcloud_options.json | 2 +- .../snapshots/session/partial_test_1.json | 2 +- .../snapshots/session/tagcloud_all_data.json | 2 +- .../snapshots/session/tagcloud_fontsize.json | 2 +- .../session/tagcloud_invalid_data.json | 2 +- .../session/tagcloud_metric_data.json | 2 +- .../snapshots/session/tagcloud_options.json | 2 +- yarn.lock | 2 +- 46 files changed, 642 insertions(+), 1393 deletions(-) delete mode 100644 src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap delete mode 100644 src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap delete mode 100644 src/plugins/vis_type_tagcloud/public/components/feedback_message.js create mode 100644 src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx delete mode 100644 src/plugins/vis_type_tagcloud/public/components/label.js delete mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud.js delete mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js create mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx delete mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js delete mode 100644 src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js diff --git a/package.json b/package.json index 513352db3f81bb8..ff2f62f51308410 100644 --- a/package.json +++ b/package.json @@ -215,7 +215,6 @@ "cytoscape-dagre": "^2.2.2", "d3": "3.5.17", "d3-array": "1.2.4", - "d3-cloud": "1.2.5", "d3-scale": "1.0.7", "d3-shape": "^1.1.0", "d3-time": "^1.1.0", diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index a12a2ff195211d4..267769d33fba2cc 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -280,7 +280,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[eCommerce] Top Selling Products', }), visState: - '{"title":"[eCommerce] Top Selling Products","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"products.product_name.keyword","size":7,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[eCommerce] Top Selling Products","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"products.product_name.keyword","size":7,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts index 05a3d012d707c17..816322dbe5299cb 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/saved_objects.ts @@ -242,7 +242,7 @@ export const getSavedObjects = (): SavedObject[] => [ defaultMessage: '[Flights] Destination Weather', }), visState: - '{"title":"[Flights] Destination Weather","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"DestWeather","size":10,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', + '{"title":"[Flights] Destination Weather","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"DestWeather","size":10,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', uiStateJSON: '{}', description: '', version: 1, diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap index 17a91a4d43cc767..cbfece0b081c611 100644 --- a/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/tag_cloud_fn.test.ts.snap @@ -5,6 +5,7 @@ Object { "as": "tagloud_vis", "type": "render", "value": Object { + "syncColors": false, "visData": Object { "columns": Array [ Object { @@ -20,6 +21,12 @@ Object { "type": "datatable", }, "visParams": Object { + "bucket": Object { + "accessor": 1, + "format": Object { + "id": "number", + }, + }, "maxFontSize": 72, "metric": Object { "accessor": 0, @@ -29,6 +36,10 @@ Object { }, "minFontSize": 18, "orientation": "single", + "palette": Object { + "name": "default", + "type": "palette", + }, "scale": "linear", "showLabel": true, }, diff --git a/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap index a8bc0b4c51678a3..fed6fb54288f27c 100644 --- a/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_type_tagcloud/public/__snapshots__/to_ast.test.ts.snap @@ -84,6 +84,9 @@ Object { "orientation": Array [ "single", ], + "palette": Array [ + "default", + ], "scale": Array [ "linear", ], diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap deleted file mode 100644 index 88ed7c66a79a2b5..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"foobarfoobar"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap deleted file mode 100644 index d7707f64d8a4fce..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"CNINUSDEBR"`; - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"CNINUSDEBR"`; - -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"CNINUSDEBR"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/feedback_message.js b/src/plugins/vis_type_tagcloud/public/components/feedback_message.js deleted file mode 100644 index 9e1d66b0a2faaea..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/feedback_message.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component, Fragment } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiIconTip } from '@elastic/eui'; - -export class FeedbackMessage extends Component { - constructor() { - super(); - this.state = { shouldShowTruncate: false, shouldShowIncomplete: false }; - } - - render() { - if (!this.state.shouldShowTruncate && !this.state.shouldShowIncomplete) { - return ''; - } - - return ( - - {this.state.shouldShowTruncate && ( -

- -

- )} - {this.state.shouldShowIncomplete && ( -

- -

- )} - - } - /> - ); - } -} diff --git a/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx b/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx new file mode 100644 index 000000000000000..82663bbf7070cae --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/get_tag_cloud_options.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy } from 'react'; +import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; +import { TagCloudVisParams, TagCloudTypeProps } from '../types'; + +const TagCloudOptionsLazy = lazy(() => import('./tag_cloud_options')); + +export const getTagCloudOptions = ({ palettes }: TagCloudTypeProps) => ( + props: VisEditorOptionsProps +) => ; diff --git a/src/plugins/vis_type_tagcloud/public/components/label.js b/src/plugins/vis_type_tagcloud/public/components/label.js deleted file mode 100644 index 028a001cfbe634e..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/label.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { Component } from 'react'; - -export class Label extends Component { - constructor() { - super(); - this.state = { label: '', shouldShowLabel: true }; - } - - render() { - return ( -
- {this.state.label} -
- ); - } -} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js deleted file mode 100644 index 254d210eebf3767..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js +++ /dev/null @@ -1,409 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import d3 from 'd3'; -import d3TagCloud from 'd3-cloud'; -import { EventEmitter } from 'events'; - -const ORIENTATIONS = { - single: () => 0, - 'right angled': (tag) => { - return hashWithinRange(tag.text, 2) * 90; - }, - multiple: (tag) => { - return hashWithinRange(tag.text, 12) * 15 - 90; //fan out 12 * 15 degrees over top-right and bottom-right quadrant (=-90 deg offset) - }, -}; -const D3_SCALING_FUNCTIONS = { - linear: () => d3.scale.linear(), - log: () => d3.scale.log(), - 'square root': () => d3.scale.sqrt(), -}; - -export class TagCloud extends EventEmitter { - constructor(domNode, colorScale) { - super(); - - //DOM - this._element = domNode; - this._d3SvgContainer = d3.select(this._element).append('svg'); - this._svgGroup = this._d3SvgContainer.append('g'); - this._size = [1, 1]; - this.resize(); - - //SETTING (non-configurable) - /** - * the fontFamily should be set explicitly for calculating a layout - * and to avoid words overlapping - */ - this._fontFamily = 'Inter UI, sans-serif'; - this._fontStyle = 'normal'; - this._fontWeight = 'normal'; - this._spiral = 'archimedean'; //layout shape - this._timeInterval = 1000; //time allowed for layout algorithm - this._padding = 5; - - //OPTIONS - this._orientation = 'single'; - this._minFontSize = 10; - this._maxFontSize = 36; - this._textScale = 'linear'; - this._optionsAsString = null; - - //DATA - this._words = null; - - //UTIL - this._colorScale = colorScale; - this._setTimeoutId = null; - this._pendingJob = null; - this._layoutIsUpdating = null; - this._allInViewBox = false; - this._DOMisUpdating = false; - } - - setOptions(options) { - if (JSON.stringify(options) === this._optionsAsString) { - return; - } - this._optionsAsString = JSON.stringify(options); - this._orientation = options.orientation; - this._minFontSize = Math.min(options.minFontSize, options.maxFontSize); - this._maxFontSize = Math.max(options.minFontSize, options.maxFontSize); - this._textScale = options.scale; - this._invalidate(false); - } - - resize() { - const newWidth = this._element.offsetWidth; - const newHeight = this._element.offsetHeight; - - if (newWidth === this._size[0] && newHeight === this._size[1]) { - return; - } - - const wasInside = this._size[0] >= this._cloudWidth && this._size[1] >= this._cloudHeight; - const willBeInside = this._cloudWidth <= newWidth && this._cloudHeight <= newHeight; - this._size[0] = newWidth; - this._size[1] = newHeight; - if (wasInside && willBeInside && this._allInViewBox) { - this._invalidate(true); - } else { - this._invalidate(false); - } - } - - setData(data) { - this._words = data; - this._invalidate(false); - } - - destroy() { - clearTimeout(this._setTimeoutId); - this._element.innerHTML = ''; - } - - getStatus() { - return this._allInViewBox ? TagCloud.STATUS.COMPLETE : TagCloud.STATUS.INCOMPLETE; - } - - _updateContainerSize() { - this._d3SvgContainer.attr('width', this._size[0]); - this._d3SvgContainer.attr('height', this._size[1]); - this._svgGroup.attr('width', this._size[0]); - this._svgGroup.attr('height', this._size[1]); - } - - _isJobRunning() { - return this._setTimeoutId || this._layoutIsUpdating || this._DOMisUpdating; - } - - async _processPendingJob() { - if (!this._pendingJob) { - return; - } - - if (this._isJobRunning()) { - return; - } - - this._completedJob = null; - const job = await this._pickPendingJob(); - if (job.words.length) { - if (job.refreshLayout) { - await this._updateLayout(job); - } - await this._updateDOM(job); - const cloudBBox = this._svgGroup[0][0].getBBox(); - this._cloudWidth = cloudBBox.width; - this._cloudHeight = cloudBBox.height; - this._allInViewBox = - cloudBBox.x >= 0 && - cloudBBox.y >= 0 && - cloudBBox.x + cloudBBox.width <= this._element.offsetWidth && - cloudBBox.y + cloudBBox.height <= this._element.offsetHeight; - } else { - this._emptyDOM(job); - } - - if (this._pendingJob) { - this._processPendingJob(); //pick up next job - } else { - this._completedJob = job; - this.emit('renderComplete'); - } - } - - async _pickPendingJob() { - return await new Promise((resolve) => { - this._setTimeoutId = setTimeout(async () => { - const job = this._pendingJob; - this._pendingJob = null; - this._setTimeoutId = null; - resolve(job); - }, 0); - }); - } - - _emptyDOM() { - this._svgGroup.selectAll('text').remove(); - this._cloudWidth = 0; - this._cloudHeight = 0; - this._allInViewBox = true; - this._DOMisUpdating = false; - } - - async _updateDOM(job) { - const canSkipDomUpdate = this._pendingJob || this._setTimeoutId; - if (canSkipDomUpdate) { - this._DOMisUpdating = false; - return; - } - - this._DOMisUpdating = true; - const affineTransform = positionWord.bind( - null, - this._element.offsetWidth / 2, - this._element.offsetHeight / 2 - ); - const svgTextNodes = this._svgGroup.selectAll('text'); - const stage = svgTextNodes.data(job.words, getText); - - await new Promise((resolve) => { - const enterSelection = stage.enter(); - const enteringTags = enterSelection.append('text'); - enteringTags.style('font-size', getSizeInPixels); - enteringTags.style('font-style', this._fontStyle); - enteringTags.style('font-weight', () => this._fontWeight); - enteringTags.style('font-family', () => this._fontFamily); - enteringTags.style('fill', this.getFill.bind(this)); - enteringTags.attr('text-anchor', () => 'middle'); - enteringTags.attr('transform', affineTransform); - enteringTags.attr('data-test-subj', getDisplayText); - enteringTags.text(getDisplayText); - - const self = this; - enteringTags.on({ - click: function (event) { - self.emit('select', event); - }, - mouseover: function () { - d3.select(this).style('cursor', 'pointer'); - }, - mouseout: function () { - d3.select(this).style('cursor', 'default'); - }, - }); - - const movingTags = stage.transition(); - movingTags.duration(600); - movingTags.style('font-size', getSizeInPixels); - movingTags.style('font-style', this._fontStyle); - movingTags.style('font-weight', () => this._fontWeight); - movingTags.style('font-family', () => this._fontFamily); - movingTags.attr('transform', affineTransform); - - const exitingTags = stage.exit(); - const exitTransition = exitingTags.transition(); - exitTransition.duration(200); - exitingTags.style('fill-opacity', 1e-6); - exitingTags.attr('font-size', 1); - exitingTags.remove(); - - let exits = 0; - let moves = 0; - const resolveWhenDone = () => { - if (exits === 0 && moves === 0) { - this._DOMisUpdating = false; - resolve(true); - } - }; - exitTransition.each(() => exits++); - exitTransition.each('end', () => { - exits--; - resolveWhenDone(); - }); - movingTags.each(() => moves++); - movingTags.each('end', () => { - moves--; - resolveWhenDone(); - }); - }); - } - - _makeTextSizeMapper() { - const mapSizeToFontSize = D3_SCALING_FUNCTIONS[this._textScale](); - const range = - this._words.length === 1 - ? [this._maxFontSize, this._maxFontSize] - : [this._minFontSize, this._maxFontSize]; - mapSizeToFontSize.range(range); - if (this._words) { - mapSizeToFontSize.domain(d3.extent(this._words, getValue)); - } - return mapSizeToFontSize; - } - - _makeNewJob() { - return { - refreshLayout: true, - size: this._size.slice(), - words: this._words, - }; - } - - _makeJobPreservingLayout() { - return { - refreshLayout: false, - size: this._size.slice(), - words: this._completedJob.words.map((tag) => { - return { - x: tag.x, - y: tag.y, - rotate: tag.rotate, - size: tag.size, - rawText: tag.rawText || tag.text, - displayText: tag.displayText, - meta: tag.meta, - }; - }), - }; - } - - _invalidate(keepLayout) { - if (!this._words) { - return; - } - - this._updateContainerSize(); - - const canReuseLayout = keepLayout && !this._isJobRunning() && this._completedJob; - this._pendingJob = canReuseLayout ? this._makeJobPreservingLayout() : this._makeNewJob(); - this._processPendingJob(); - } - - async _updateLayout(job) { - if (job.size[0] <= 0 || job.size[1] <= 0) { - // If either width or height isn't above 0 we don't relayout anything, - // since the d3-cloud will be stuck in an infinite loop otherwise. - return; - } - - const mapSizeToFontSize = this._makeTextSizeMapper(); - const tagCloudLayoutGenerator = d3TagCloud(); - tagCloudLayoutGenerator.size(job.size); - tagCloudLayoutGenerator.padding(this._padding); - tagCloudLayoutGenerator.rotate(ORIENTATIONS[this._orientation]); - tagCloudLayoutGenerator.font(this._fontFamily); - tagCloudLayoutGenerator.fontStyle(this._fontStyle); - tagCloudLayoutGenerator.fontWeight(this._fontWeight); - tagCloudLayoutGenerator.fontSize((tag) => mapSizeToFontSize(tag.value)); - tagCloudLayoutGenerator.random(seed); - tagCloudLayoutGenerator.spiral(this._spiral); - tagCloudLayoutGenerator.words(job.words); - tagCloudLayoutGenerator.text(getDisplayText); - tagCloudLayoutGenerator.timeInterval(this._timeInterval); - - this._layoutIsUpdating = true; - await new Promise((resolve) => { - tagCloudLayoutGenerator.on('end', () => { - this._layoutIsUpdating = false; - resolve(true); - }); - tagCloudLayoutGenerator.start(); - }); - } - - /** - * Returns debug info. For debugging only. - * @return {*} - */ - getDebugInfo() { - const debug = {}; - debug.positions = this._completedJob - ? this._completedJob.words.map((tag) => { - return { - displayText: tag.displayText, - rawText: tag.rawText || tag.text, - x: tag.x, - y: tag.y, - rotate: tag.rotate, - }; - }) - : []; - debug.size = { - width: this._size[0], - height: this._size[1], - }; - return debug; - } - - getFill(tag) { - return this._colorScale(tag.text); - } -} - -TagCloud.STATUS = { COMPLETE: 0, INCOMPLETE: 1 }; - -function seed() { - return 0.5; //constant seed (not random) to ensure constant layouts for identical data -} - -function getText(word) { - return word.rawText; -} - -function getDisplayText(word) { - return word.displayText; -} - -function positionWord(xTranslate, yTranslate, word) { - if (isNaN(word.x) || isNaN(word.y) || isNaN(word.rotate)) { - //move off-screen - return `translate(${xTranslate * 3}, ${yTranslate * 3})rotate(0)`; - } - - return `translate(${word.x + xTranslate}, ${word.y + yTranslate})rotate(${word.rotate})`; -} - -function getValue(tag) { - return tag.value; -} - -function getSizeInPixels(tag) { - return `${tag.size}px`; -} - -function hashWithinRange(str, max) { - str = JSON.stringify(str); - let hash = 0; - for (const ch of str) { - hash = (hash * 31 + ch.charCodeAt(0)) % max; - } - return Math.abs(hash) % max; -} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss index 37867f1ed1c178c..51b5e9dedd84420 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.scss @@ -5,18 +5,14 @@ // tgcChart__legend--small // tgcChart__legend-isLoading -.tgcChart__container, .tgcChart__wrapper { +.tgcChart__wrapper { flex: 1 1 0; display: flex; + flex-direction: column; } -.tgcChart { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; +.tgcChart__wrapper text { + cursor: pointer; } .tgcChart__label { @@ -24,3 +20,7 @@ text-align: center; font-weight: $euiFontWeightBold; } + +.tgcChart__warning { + width: $euiSize; +} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js deleted file mode 100644 index eb575457146c5d6..000000000000000 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.test.js +++ /dev/null @@ -1,507 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import d3 from 'd3'; -import 'jest-canvas-mock'; - -import { fromNode, delay } from 'bluebird'; -import { TagCloud } from './tag_cloud'; -import { setHTMLElementOffset, setSVGElementGetBBox } from '@kbn/test/jest'; - -describe('tag cloud tests', () => { - let SVGElementGetBBoxSpyInstance; - let HTMLElementOffsetMockInstance; - - beforeEach(() => { - setupDOM(); - }); - - afterEach(() => { - SVGElementGetBBoxSpyInstance.mockRestore(); - HTMLElementOffsetMockInstance.mockRestore(); - }); - - const minValue = 1; - const maxValue = 9; - const midValue = (minValue + maxValue) / 2; - const baseTest = { - data: [ - { rawText: 'foo', displayText: 'foo', value: minValue }, - { rawText: 'bar', displayText: 'bar', value: midValue }, - { rawText: 'foobar', displayText: 'foobar', value: maxValue }, - ], - options: { - orientation: 'single', - scale: 'linear', - minFontSize: 10, - maxFontSize: 36, - }, - expected: [ - { - text: 'foo', - fontSize: '10px', - }, - { - text: 'bar', - fontSize: '23px', - }, - { - text: 'foobar', - fontSize: '36px', - }, - ], - }; - - const singleLayoutTest = _.cloneDeep(baseTest); - - const rightAngleLayoutTest = _.cloneDeep(baseTest); - rightAngleLayoutTest.options.orientation = 'right angled'; - - const multiLayoutTest = _.cloneDeep(baseTest); - multiLayoutTest.options.orientation = 'multiple'; - - const mapWithLog = d3.scale.log(); - mapWithLog.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]); - mapWithLog.domain([minValue, maxValue]); - const logScaleTest = _.cloneDeep(baseTest); - logScaleTest.options.scale = 'log'; - logScaleTest.expected[1].fontSize = Math.round(mapWithLog(midValue)) + 'px'; - - const mapWithSqrt = d3.scale.sqrt(); - mapWithSqrt.range([baseTest.options.minFontSize, baseTest.options.maxFontSize]); - mapWithSqrt.domain([minValue, maxValue]); - const sqrtScaleTest = _.cloneDeep(baseTest); - sqrtScaleTest.options.scale = 'square root'; - sqrtScaleTest.expected[1].fontSize = Math.round(mapWithSqrt(midValue)) + 'px'; - - const biggerFontTest = _.cloneDeep(baseTest); - biggerFontTest.options.minFontSize = 36; - biggerFontTest.options.maxFontSize = 72; - biggerFontTest.expected[0].fontSize = '36px'; - biggerFontTest.expected[1].fontSize = '54px'; - biggerFontTest.expected[2].fontSize = '72px'; - - const trimDataTest = _.cloneDeep(baseTest); - trimDataTest.data.splice(1, 1); - trimDataTest.expected.splice(1, 1); - - let domNode; - let tagCloud; - - const colorScale = d3.scale - .ordinal() - .range(['#00a69b', '#57c17b', '#6f87d8', '#663db8', '#bc52bc', '#9e3533', '#daa05d']); - - function setupDOM() { - domNode = document.createElement('div'); - SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(); - HTMLElementOffsetMockInstance = setHTMLElementOffset(512, 512); - - document.body.appendChild(domNode); - } - - function teardownDOM() { - domNode.innerHTML = ''; - document.body.removeChild(domNode); - } - - [ - singleLayoutTest, - rightAngleLayoutTest, - multiLayoutTest, - logScaleTest, - sqrtScaleTest, - biggerFontTest, - trimDataTest, - ].forEach(function (currentTest) { - describe(`should position elements correctly for options: ${JSON.stringify( - currentTest.options - )}`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(currentTest.data); - tagCloud.setOptions(currentTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(currentTest.expected, textElements, tagCloud); - }) - ); - }); - }); - - [5, 100, 200, 300, 500].forEach((timeout) => { - // FLAKY: https://github.com/elastic/kibana/issues/94043 - describe.skip(`should only send single renderComplete event at the very end, using ${timeout}ms timeout`, () => { - beforeEach(async () => { - //TagCloud takes at least 600ms to complete (due to d3 animation) - //renderComplete should only notify at the last one - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - //this timeout modifies the settings before the cloud is rendered. - //the cloud needs to use the correct options - setTimeout(() => tagCloud.setOptions(logScaleTest.options), timeout); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - }); - - describe('should use the latest state before notifying (when modifying options multiple times)', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - tagCloud.setOptions(logScaleTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should use the latest state before notifying (when modifying data multiple times)', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - tagCloud.setData(trimDataTest.data); - - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(trimDataTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should not get multiple render-events', () => { - let counter; - beforeEach(() => { - counter = 0; - - return new Promise((resolve, reject) => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - setTimeout(() => { - //this should be overridden by later changes - tagCloud.setData(sqrtScaleTest.data); - tagCloud.setOptions(sqrtScaleTest.options); - }, 100); - - setTimeout(() => { - //latest change - tagCloud.setData(logScaleTest.data); - tagCloud.setOptions(logScaleTest.options); - }, 300); - - tagCloud.on('renderComplete', function onRender() { - if (counter > 0) { - reject('Should not get multiple render events'); - } - counter += 1; - resolve(true); - }); - }); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(logScaleTest.expected, textElements, tagCloud); - }) - ); - }); - - describe('should show correct data when state-updates are interleaved with resize event', () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(logScaleTest.data); - tagCloud.setOptions(logScaleTest.options); - - await delay(1000); //let layout run - - SVGElementGetBBoxSpyInstance.mockRestore(); - SVGElementGetBBoxSpyInstance = setSVGElementGetBBox(600, 600); - - tagCloud.resize(); //triggers new layout - setTimeout(() => { - //change the options at the very end too - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - }, 200); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - test( - 'positions should be ok', - handleExpectedBlip(() => { - const textElements = domNode.querySelectorAll('text'); - verifyTagProperties(baseTest.expected, textElements, tagCloud); - }) - ); - }); - - describe(`should not put elements in view when container is too small`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test('completeness should not be ok', () => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE); - }); - test('positions should not be ok', () => { - const textElements = domNode.querySelectorAll('text'); - for (let i = 0; i < textElements; i++) { - const bbox = textElements[i].getBoundingClientRect(); - verifyBbox(bbox, false, tagCloud); - } - }); - }); - - describe(`tags should fit after making container bigger`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - //make bigger - tagCloud._size = [600, 600]; - tagCloud.resize(); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test( - 'completeness should be ok', - handleExpectedBlip(() => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.COMPLETE); - }) - ); - }); - - describe(`tags should no longer fit after making container smaller`, () => { - beforeEach(async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - //make smaller - tagCloud._size = []; - tagCloud.resize(); - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - }); - - afterEach(teardownDOM); - - test('completeness should not be ok', () => { - expect(tagCloud.getStatus()).toEqual(TagCloud.STATUS.INCOMPLETE); - }); - }); - - describe('tagcloudscreenshot', () => { - afterEach(teardownDOM); - - test('should render simple image', async () => { - tagCloud = new TagCloud(domNode, colorScale); - tagCloud.setData(baseTest.data); - tagCloud.setOptions(baseTest.options); - - await fromNode((cb) => tagCloud.once('renderComplete', cb)); - - expect(domNode.innerHTML).toMatchSnapshot(); - }); - }); - - function verifyTagProperties(expectedValues, actualElements, tagCloud) { - expect(actualElements.length).toEqual(expectedValues.length); - expectedValues.forEach((test, index) => { - try { - expect(actualElements[index].style.fontSize).toEqual(test.fontSize); - } catch (e) { - throw new Error('fontsize is not correct: ' + e.message); - } - try { - expect(actualElements[index].innerHTML).toEqual(test.text); - } catch (e) { - throw new Error('fontsize is not correct: ' + e.message); - } - isInsideContainer(actualElements[index], tagCloud); - }); - } - - function isInsideContainer(actualElement, tagCloud) { - const bbox = actualElement.getBoundingClientRect(); - verifyBbox(bbox, true, tagCloud); - } - - function verifyBbox(bbox, shouldBeInside, tagCloud) { - const message = ` | bbox-of-tag: ${JSON.stringify([ - bbox.left, - bbox.top, - bbox.right, - bbox.bottom, - ])} vs - bbox-of-container: ${domNode.offsetWidth},${domNode.offsetHeight} - debugInfo: ${JSON.stringify(tagCloud.getDebugInfo())}`; - - try { - expect(bbox.top >= 0 && bbox.top <= domNode.offsetHeight).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'top boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message - ); - } - try { - expect(bbox.bottom >= 0 && bbox.bottom <= domNode.offsetHeight).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'bottom boundary of tag should have been ' + - (shouldBeInside ? 'inside' : 'outside') + - message - ); - } - try { - expect(bbox.left >= 0 && bbox.left <= domNode.offsetWidth).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'left boundary of tag should have been ' + (shouldBeInside ? 'inside' : 'outside') + message - ); - } - try { - expect(bbox.right >= 0 && bbox.right <= domNode.offsetWidth).toBe(shouldBeInside); - } catch (e) { - throw new Error( - 'right boundary of tag should have been ' + - (shouldBeInside ? 'inside' : 'outside') + - message - ); - } - } - - /** - * In CI, this entire suite "blips" about 1/5 times. - * This blip causes the majority of these tests fail for the exact same reason: One tag is centered inside the container, - * while the others are moved out. - * This has not been reproduced locally yet. - * It may be an issue with the 3rd party d3-cloud that snags. - * - * The test suite should continue to catch reliably catch regressions of other sorts: unexpected and other uncaught errors, - * scaling issues, ordering issues - * - */ - function shouldAssert() { - const debugInfo = tagCloud.getDebugInfo(); - const count = debugInfo.positions.length; - const largest = debugInfo.positions.pop(); //test suite puts largest tag at the end. - - const centered = largest[1] === 0 && largest[2] === 0; - const halfWidth = debugInfo.size.width / 2; - const halfHeight = debugInfo.size.height / 2; - const inside = debugInfo.positions.filter((position) => { - const x = position.x + halfWidth; - const y = position.y + halfHeight; - return 0 <= x && x <= debugInfo.size.width && 0 <= y && y <= debugInfo.size.height; - }); - - return centered && inside.length === count - 1; - } - - function handleExpectedBlip(assertion) { - return () => { - if (!shouldAssert()) { - return; - } - assertion(); - }; - } -}); diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.tsx new file mode 100644 index 000000000000000..b4d4e70d5ffe3e2 --- /dev/null +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.test.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { Wordcloud, Settings } from '@elastic/charts'; +import { chartPluginMock } from '../../../charts/public/mocks'; +import type { Datatable } from '../../../expressions/public'; +import { mount } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import TagCloudChart, { TagCloudChartProps } from './tag_cloud_chart'; +import { TagCloudVisParams } from '../types'; + +jest.mock('../services', () => ({ + getFormatService: jest.fn(() => { + return { + deserialize: jest.fn(), + }; + }), +})); + +const palettesRegistry = chartPluginMock.createPaletteRegistry(); +const visData = ({ + columns: [ + { + id: 'col-0', + name: 'geo.dest: Descending', + }, + { + id: 'col-1', + name: 'Count', + }, + ], + rows: [ + { 'col-0': 'CN', 'col-1': 26 }, + { 'col-0': 'IN', 'col-1': 17 }, + { 'col-0': 'US', 'col-1': 6 }, + { 'col-0': 'DE', 'col-1': 4 }, + { 'col-0': 'BR', 'col-1': 3 }, + ], +} as unknown) as Datatable; + +const visParams = { + bucket: { accessor: 0, format: {} }, + metric: { accessor: 1, format: {} }, + scale: 'linear', + orientation: 'single', + palette: { + type: 'palette', + name: 'default', + }, + minFontSize: 12, + maxFontSize: 70, + showLabel: true, +} as TagCloudVisParams; + +describe('TagCloudChart', function () { + let wrapperProps: TagCloudChartProps; + + beforeAll(() => { + wrapperProps = { + visData, + visParams, + palettesRegistry, + fireEvent: jest.fn(), + renderComplete: jest.fn(), + syncColors: false, + visType: 'tagcloud', + }; + }); + + it('renders the Wordcloud component', async () => { + const component = mount(); + expect(component.find(Wordcloud).length).toBe(1); + }); + + it('renders the label correctly', async () => { + const component = mount(); + const label = findTestSubject(component, 'tagCloudLabel'); + expect(label.text()).toEqual('geo.dest: Descending - Count'); + }); + + it('not renders the label if showLabel setting is off', async () => { + const newVisParams = { ...visParams, showLabel: false }; + const newProps = { ...wrapperProps, visParams: newVisParams }; + const component = mount(); + const label = findTestSubject(component, 'tagCloudLabel'); + expect(label.length).toBe(0); + }); + + it('receives the data on the correct format', () => { + const component = mount(); + expect(component.find(Wordcloud).prop('data')).toStrictEqual([ + { + color: 'black', + text: 'CN', + weight: 1, + }, + { + color: 'black', + text: 'IN', + weight: 0.6086956521739131, + }, + { + color: 'black', + text: 'US', + weight: 0.13043478260869565, + }, + { + color: 'black', + text: 'DE', + weight: 0.043478260869565216, + }, + { + color: 'black', + text: 'BR', + weight: 0, + }, + ]); + }); + + it('sets the angles correctly', async () => { + const newVisParams = { ...visParams, orientation: 'right angled' } as TagCloudVisParams; + const newProps = { ...wrapperProps, visParams: newVisParams }; + const component = mount(); + expect(component.find(Wordcloud).prop('endAngle')).toBe(90); + expect(component.find(Wordcloud).prop('angleCount')).toBe(2); + }); + + it('calls filter callback', () => { + const component = mount(); + component.find(Settings).prop('onElementClick')!([ + [ + { + text: 'BR', + weight: 0.17391304347826086, + color: '#d36086', + }, + { + specId: 'tagCloud', + key: 'tagCloud', + }, + ], + ]); + expect(wrapperProps.fireEvent).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx index f668e22815b60f2..b89fe2fa90ede0a 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx @@ -6,64 +6,225 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo, useRef } from 'react'; -import { EuiResizeObserver } from '@elastic/eui'; +import React, { useCallback, useState, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { throttle } from 'lodash'; - -import { TagCloudVisDependencies } from '../plugin'; +import { EuiIconTip, EuiResizeObserver } from '@elastic/eui'; +import { Chart, Settings, Wordcloud, RenderChangeListener } from '@elastic/charts'; +import type { PaletteRegistry } from '../../../charts/public'; +import type { IInterpreterRenderHandlers } from '../../../expressions/public'; +import { getFormatService } from '../services'; import { TagCloudVisRenderValue } from '../tag_cloud_fn'; -// @ts-ignore -import { TagCloudVisualization } from './tag_cloud_visualization'; import './tag_cloud.scss'; -type TagCloudChartProps = TagCloudVisDependencies & - TagCloudVisRenderValue & { - fireEvent: (event: any) => void; - renderComplete: () => void; - }; +const MAX_TAG_COUNT = 200; + +export type TagCloudChartProps = TagCloudVisRenderValue & { + fireEvent: IInterpreterRenderHandlers['event']; + renderComplete: IInterpreterRenderHandlers['done']; + palettesRegistry: PaletteRegistry; +}; + +const calculateWeight = (value: number, x1: number, y1: number, x2: number, y2: number) => + ((value - x1) * (y2 - x2)) / (y1 - x1) + x2; + +const getColor = ( + palettes: PaletteRegistry, + activePalette: string, + text: string, + values: string[], + syncColors: boolean +) => { + return palettes?.get(activePalette).getCategoricalColor( + [ + { + name: text, + rankAtDepth: values.length ? values.findIndex((name) => name === text) : 0, + totalSeriesAtDepth: values.length || 1, + }, + ], + { + maxDepth: 1, + totalSeries: values.length || 1, + behindText: false, + syncColors, + } + ); +}; + +const ORIENTATIONS = { + single: { + endAngle: 0, + angleCount: 360, + }, + 'right angled': { + endAngle: 90, + angleCount: 2, + }, + multiple: { + endAngle: -90, + angleCount: 12, + }, +}; export const TagCloudChart = ({ - colors, visData, visParams, + palettesRegistry, fireEvent, renderComplete, + syncColors, }: TagCloudChartProps) => { - const chartDiv = useRef(null); - const visController = useRef(null); + const [warning, setWarning] = useState(false); + const { bucket, metric, scale, palette, showLabel, orientation } = visParams; + const bucketFormatter = bucket ? getFormatService().deserialize(bucket.format) : null; - useEffect(() => { - if (chartDiv.current) { - visController.current = new TagCloudVisualization(chartDiv.current, colors, fireEvent); - } - return () => { - visController.current.destroy(); - visController.current = null; - }; - }, [colors, fireEvent]); - - useEffect(() => { - if (visController.current) { - visController.current.render(visData, visParams).then(renderComplete); - } - }, [visData, visParams, renderComplete]); + const tagCloudData = useMemo(() => { + const tagColumn = bucket ? visData.columns[bucket.accessor].id : -1; + const metricColumn = visData.columns[metric.accessor]?.id; + + const metrics = visData.rows.map((row) => row[metricColumn]); + const values = bucket ? visData.rows.map((row) => row[tagColumn]) : []; + const maxValue = Math.max(...metrics); + const minValue = Math.min(...metrics); + + return visData.rows.map((row) => { + const tag = row[tagColumn] === undefined ? 'all' : row[tagColumn]; + return { + text: (bucketFormatter ? bucketFormatter.convert(tag, 'text') : tag) as string, + weight: + tag === 'all' || visData.rows.length <= 1 + ? 1 + : calculateWeight(row[metricColumn], minValue, maxValue, 0, 1) || 0, + color: getColor(palettesRegistry, palette.name, tag, values, syncColors) || 'rgba(0,0,0,0)', + }; + }); + }, [ + bucket, + bucketFormatter, + metric.accessor, + palette.name, + palettesRegistry, + syncColors, + visData.columns, + visData.rows, + ]); + + const label = bucket + ? `${visData.columns[bucket.accessor].name} - ${visData.columns[metric.accessor].name}` + : ''; + + const onRenderChange = useCallback( + (isRendered) => { + if (isRendered) { + renderComplete(); + } + }, + [renderComplete] + ); - const updateChartSize = useMemo( + const updateChart = useMemo( () => throttle(() => { - if (visController.current) { - visController.current.render(visData, visParams).then(renderComplete); - } + setWarning(false); }, 300), - [renderComplete, visData, visParams] + [] + ); + + const handleWordClick = useCallback( + (d) => { + if (!bucket) { + return; + } + const termsBucket = visData.columns[bucket.accessor]; + const clickedValue = d[0][0].text; + + const rowIndex = visData.rows.findIndex((row) => { + const formattedValue = bucketFormatter + ? bucketFormatter.convert(row[termsBucket.id], 'text') + : row[termsBucket.id]; + return formattedValue === clickedValue; + }); + + if (rowIndex < 0) { + return; + } + + fireEvent({ + name: 'filterBucket', + data: { + data: [ + { + table: visData, + column: bucket.accessor, + row: rowIndex, + }, + ], + }, + }); + }, + [bucket, bucketFormatter, fireEvent, visData] ); return ( - + {(resizeRef) => ( -
-
+
+ + + { + setWarning(true); + }} + /> + + {label && showLabel && ( +
+ {label} +
+ )} + {warning && ( +
+ + } + /> +
+ )} + {tagCloudData.length > MAX_TAG_COUNT && ( +
+ + } + /> +
+ )}
)} diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx index d5e005a63868060..6682799a8038adc 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_options.tsx @@ -6,16 +6,22 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { SelectOption, SwitchOption } from '../../../vis_default_editor/public'; +import type { PaletteRegistry } from '../../../charts/public'; +import { VisEditorOptionsProps } from '../../../visualizations/public'; +import { SelectOption, SwitchOption, PalettePicker } from '../../../vis_default_editor/public'; import { ValidatedDualRange } from '../../../kibana_react/public'; -import { TagCloudVisParams } from '../types'; +import { TagCloudVisParams, TagCloudTypeProps } from '../types'; import { collections } from './collections'; -function TagCloudOptions({ stateParams, setValue }: VisEditorOptionsProps) { +interface TagCloudOptionsProps + extends VisEditorOptionsProps, + TagCloudTypeProps {} + +function TagCloudOptions({ stateParams, setValue, palettes }: TagCloudOptionsProps) { + const [palettesRegistry, setPalettesRegistry] = useState(undefined); const handleFontSizeChange = ([minFontSize, maxFontSize]: [string | number, string | number]) => { setValue('minFontSize', Number(minFontSize)); setValue('maxFontSize', Number(maxFontSize)); @@ -24,6 +30,14 @@ function TagCloudOptions({ stateParams, setValue }: VisEditorOptionsProps { + const fetchPalettes = async () => { + const palettesService = await palettes?.getPalettes(); + setPalettesRegistry(palettesService); + }; + fetchPalettes(); + }, [palettes]); + return ( + {palettesRegistry && ( + { + setValue(paramName, value); + }} + /> + )} + { - if (!this._visParams.bucket) { - return; - } - - fireEvent({ - name: 'filterBucket', - data: { - data: [ - { - table: event.meta.data, - column: 0, - row: event.meta.rowIndex, - }, - ], - }, - }); - }); - this._renderComplete$ = Rx.fromEvent(this._tagCloud, 'renderComplete'); - - this._feedbackNode = document.createElement('div'); - this._containerNode.appendChild(this._feedbackNode); - this._feedbackMessage = React.createRef(); - render( - - - , - this._feedbackNode - ); - - this._labelNode = document.createElement('div'); - this._containerNode.appendChild(this._labelNode); - this._label = React.createRef(); - render(