diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 0b56007cf9aeb..5c3f6aef0271e 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -243,6 +243,7 @@ enabled: - x-pack/test/functional/config_security_basic.ts - x-pack/test/functional/config.ccs.ts - x-pack/test/functional/config.firefox.js + - x-pack/test/functional_cloud/config.ts - x-pack/test/kubernetes_security/basic/config.ts - x-pack/test/licensing_plugin/config.public.ts - x-pack/test/licensing_plugin/config.ts diff --git a/docs/osquery/exported-fields-reference.asciidoc b/docs/osquery/exported-fields-reference.asciidoc index c27b6e67a4062..fc16ec3e0d9d0 100644 --- a/docs/osquery/exported-fields-reference.asciidoc +++ b/docs/osquery/exported-fields-reference.asciidoc @@ -82,7 +82,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *activity* - keyword, number.long -* _unified_log.activity_ - the activity ID associate with the entry. +* _unified_log.activity_ - the activity ID associate with the entry *actual* - keyword, number.long @@ -101,7 +101,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _arp_cache.address_ - IPv4 address target * _dns_resolvers.address_ - Resolver IP/IPv6 address * _etc_hosts.address_ - IP address mapping -* _fbsd_kmods.address_ - Kernel module address * _interface_addresses.address_ - Specific address for interface * _kernel_modules.address_ - Kernel module address * _listening_ports.address_ - Specific address for bind @@ -187,7 +186,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _deb_packages.arch_ - Package architecture * _docker_version.arch_ - Hardware architecture * _os_version.arch_ - OS Architecture -* _pkg_packages.arch_ - Architecture(s) supported * _rpm_packages.arch_ - Architecture(s) supported * _seccomp_events.arch_ - Information about the CPU architecture * _signature.arch_ - If applicable, the arch of the signed code @@ -247,6 +245,42 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _chassis_info.audible_alarm_ - If TRUE, the frame is equipped with an audible alarm. +*audit_account_logon* - keyword, number.long + +* _security_profile_info.audit_account_logon_ - Determines whether the operating system MUST audit each time this computer validates the credentials of an account + +*audit_account_manage* - keyword, number.long + +* _security_profile_info.audit_account_manage_ - Determines whether the operating system MUST audit each event of account management on a computer + +*audit_ds_access* - keyword, number.long + +* _security_profile_info.audit_ds_access_ - Determines whether the operating system MUST audit each instance of user attempts to access an Active Directory object that has its own system access control list (SACL) specified + +*audit_logon_events* - keyword, number.long + +* _security_profile_info.audit_logon_events_ - Determines whether the operating system MUST audit each instance of a user attempt to log on or log off this computer + +*audit_object_access* - keyword, number.long + +* _security_profile_info.audit_object_access_ - Determines whether the operating system MUST audit each instance of user attempts to access a non-Active Directory object that has its own SACL specified + +*audit_policy_change* - keyword, number.long + +* _security_profile_info.audit_policy_change_ - Determines whether the operating system MUST audit each instance of user attempts to change user rights assignment policy, audit policy, account policy, or trust policy + +*audit_privilege_use* - keyword, number.long + +* _security_profile_info.audit_privilege_use_ - Determines whether the operating system MUST audit each instance of user attempts to exercise a user right + +*audit_process_tracking* - keyword, number.long + +* _security_profile_info.audit_process_tracking_ - Determines whether the operating system MUST audit process-related events + +*audit_system_events* - keyword, number.long + +* _security_profile_info.audit_system_events_ - Determines whether the operating system MUST audit System Change, System Startup, System Shutdown, Authentication Component Load, and Loss or Excess of Security events + *auid* - keyword * _process_events.auid_ - Audit User ID at process start @@ -625,7 +659,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _ntfs_journal_events.category_ - The category that the event originated from * _power_sensors.category_ - The sensor category: currents, voltage, wattage * _system_extensions.category_ - System extension category -* _unified_log.category_ - The category of the os_log_t used +* _unified_log.category_ - the category of the os_log_t used * _yara_events.category_ - The category of the file *cdhash* - keyword, text.text @@ -731,6 +765,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _wmi_filter_consumer_binding.class_ - The name of the class. * _wmi_script_event_consumers.class_ - The name of the class. +*clear_text_password* - keyword, number.long + +* _security_profile_info.clear_text_password_ - Determines whether passwords MUST be stored by using reversible encryption + *client_app_id* - keyword, text.text * _windows_update_history.client_app_id_ - Identifier of the client application that processed an update @@ -767,6 +805,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _os_version.codename_ - OS version codename +*codesigning_flags* - keyword, text.text + +* _es_process_events.codesigning_flags_ - Codesigning flags matching one of these options, in a comma separated list: NOT_VALID, ADHOC, NOT_RUNTIME, INSTALLER. See kern/cs_blobs.h in XNU for descriptions. + *collect_cross_processes* - keyword, number.long * _carbon_black_info.collect_cross_processes_ - If the sensor is configured to cross process events @@ -848,7 +890,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _authorized_keys.comment_ - Optional comment * _docker_image_history.comment_ - Instruction comment * _etc_protocols.comment_ - Comment with protocol description -* _etc_services.comment_ - Optional comment for a service +* _etc_services.comment_ - Optional comment for a service. * _groups.comment_ - Remarks or comments associated with the group * _keychain_items.comment_ - Optional keychain comment @@ -1092,7 +1134,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _docker_image_history.created_ - Time of creation as UNIX time * _docker_images.created_ - Time of creation as UNIX time * _docker_networks.created_ - Time of creation as UNIX time -* _keychain_items.created_ - Data item was created +* _keychain_items.created_ - Date item was created *created_at* - keyword, text.text @@ -1590,6 +1632,14 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _processes.elevated_token_ - Process uses elevated token yes=1, no=0 +*enable_admin_account* - keyword, number.long + +* _security_profile_info.enable_admin_account_ - Determines whether the Administrator account on the local computer is enabled + +*enable_guest_account* - keyword, number.long + +* _security_profile_info.enable_guest_account_ - Determines whether the Guest account on the local computer is enabled + *enable_ipv6* - keyword, number.long * _docker_networks.enable_ipv6_ - 1 if IPv6 is enabled on this network. 0 otherwise @@ -1949,7 +1999,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *firmware_type* - keyword, text.text -* _platform_info.firmware_type_ - The type of firmware (Uefi, Bios, Unknown). +* _platform_info.firmware_type_ - The type of firmware (uefi, bios, iboot, openfirmware, unknown). *firmware_version* - keyword, text.text @@ -1972,10 +2022,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _pipes.flags_ - The flags indicating whether this pipe connection is a server or client end, and if the pipe for sending messages or bytes * _routes.flags_ - Flags to describe route -*flatsize* - keyword, number.long - -* _pkg_packages.flatsize_ - Package size in bytes - *folder_id* - keyword, text.text * _ycloud_instance_metadata.folder_id_ - Folder identifier for the VM @@ -1984,6 +2030,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _systemd_units.following_ - The name of another unit that this unit follows in state +*force_logoff_when_expire* - keyword, number.long + +* _security_profile_info.force_logoff_when_expire_ - Determines whether SMB client sessions with the SMB server will be forcibly disconnected when the client's logon hours expire + *forced* - keyword, number.long * _preferences.forced_ - 1 if the value is forced/managed, else 0 @@ -2250,7 +2300,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *hostname* - keyword, text.text -* _curl_certificate.hostname_ - Hostname to CURL (domain[:port], for example, osquery.io) +* _curl_certificate.hostname_ - Hostname to CURL (domain[:port], e.g. osquery.io) * _system_info.hostname_ - Network hostname including domain * _ycloud_instance_metadata.hostname_ - Hostname of the VM @@ -2626,7 +2676,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *is_active* - keyword, number.long -* _running_apps.is_active_ - 1 if the application is in focus, 0 otherwise +* _running_apps.is_active_ - (DEPRECATED) *is_hidden* - keyword, number.long @@ -2949,6 +2999,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _shared_memory.locked_ - 1 if segment is locked else 0 +*lockout_bad_count* - keyword, number.long + +* _security_profile_info.lockout_bad_count_ - Number of failed logon attempts after which a user account MUST be locked out + *log_file_disk_quota_mb* - keyword, number.long * _carbon_black_info.log_file_disk_quota_mb_ - Event file disk quota in MB @@ -2997,10 +3051,18 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _logon_sessions.logon_time_ - The time the session owner logged on. +*logon_to_change_password* - keyword, number.long + +* _security_profile_info.logon_to_change_password_ - Determines if logon session is required to change the password + *logon_type* - keyword, text.text * _logon_sessions.logon_type_ - The logon method. +*lsa_anonymous_name_lookup* - keyword, number.long + +* _security_profile_info.lsa_anonymous_name_lookup_ - Determines if an anonymous user is allowed to query the local LSA policy + *mac* - keyword, text.text * _arp_cache.mac_ - MAC address of broadcasted address @@ -3110,7 +3172,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *max_rows* - keyword, number.long -* _unified_log.max_rows_ - The max number of rows returned (defaults to 100). +* _unified_log.max_rows_ - the max number of rows returned (defaults to 100) *max_speed* - keyword, number.long @@ -3124,6 +3186,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _shared_resources.maximum_allowed_ - Limit on the maximum number of users allowed to use this resource concurrently. The value is only valid if the AllowMaximum property is set to FALSE. +*maximum_password_age* - keyword, number.long + +* _security_profile_info.maximum_password_age_ - Determines the maximum number of days that a password can be used before the client requires the user to change it + *md5* - keyword, text.text * _acpi_tables.md5_ - MD5 hash of table content @@ -3240,7 +3306,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _lxd_cluster_members.message_ - Message from the node (Online/Offline) * _selinux_events.message_ - Message * _syslog_events.message_ - The syslog message -* _unified_log.message_ - Composed message +* _unified_log.message_ - composed message * _user_events.message_ - Message from the event *metadata_endpoint* - keyword, text.text @@ -3297,6 +3363,14 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _memory_devices.min_voltage_ - Minimum operating voltage of device in millivolts +*minimum_password_age* - keyword, number.long + +* _security_profile_info.minimum_password_age_ - Determines the minimum number of days that a password must be used before the user can change it + +*minimum_password_length* - keyword, number.long + +* _security_profile_info.minimum_password_length_ - Determines the least number of characters that can make up a password for a user account + *minimum_system_version* - keyword, text.text * _apps.minimum_system_version_ - Minimum version of macOS required for the app to run @@ -3459,7 +3533,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _etc_protocols.name_ - Protocol name * _etc_services.name_ - Service name * _fan_speed_sensors.name_ - Fan name -* _fbsd_kmods.name_ - Module name * _firefox_addons.name_ - Addon display name * _homebrew_packages.name_ - Package name * _ie_extensions.name_ - Extension display name @@ -3491,7 +3564,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _package_install_history.name_ - Package display name * _physical_disk_performance.name_ - Name of the physical disk * _pipes.name_ - Name of the pipe -* _pkg_packages.name_ - Package name * _power_sensors.name_ - Name of power source * _processes.name_ - The process path or shorthand argv[0] * _programs.name_ - Commonly used product name. @@ -3529,7 +3601,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *native* - keyword, number.long * _browser_plugins.native_ - Plugin requires native execution -* _firefox_addons.native_ - 1 If the addon includes binary components else 0 *net_namespace* - keyword, text.text @@ -3561,6 +3632,14 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _docker_container_stats.network_tx_bytes_ - Total network bytes transmitted +*new_administrator_name* - keyword, text.text + +* _security_profile_info.new_administrator_name_ - Determines the name of the Administrator account on the local computer + +*new_guest_name* - keyword, text.text + +* _security_profile_info.new_guest_name_ - Determines the name of the Guest account on the local computer + *next_run_time* - keyword, number.long * _scheduled_tasks.next_run_time_ - Timestamp the task is scheduled to run next @@ -3916,6 +3995,14 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _wifi_networks.passpoint_ - 1 if Passpoint is supported, 0 otherwise +*password_complexity* - keyword, number.long + +* _security_profile_info.password_complexity_ - Determines whether passwords must meet a series of strong-password guidelines + +*password_history_size* - keyword, number.long + +* _security_profile_info.password_history_size_ - Number of unique new passwords that must be associated with a user account before an old password can be reused + *password_last_set_time* - keyword, number.double * _account_policy_data.password_last_set_time_ - The time the password was last changed @@ -4150,10 +4237,10 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _processes.pid_ - Process (or thread) ID * _running_apps.pid_ - The pid of the application * _seccomp_events.pid_ - Process ID -* _services.pid_ - The Process ID of the service +* _services.pid_ - the Process ID of the service * _shared_memory.pid_ - Process ID to last use the segment * _socket_events.pid_ - Process (or thread) ID -* _unified_log.pid_ - The pid of the process that made the entry +* _unified_log.pid_ - the pid of the process that made the entry * _user_events.pid_ - Process (or thread) ID * _windows_crashes.pid_ - Process ID of the crashed process * _windows_eventlog.pid_ - Process ID which emitted the event record @@ -4327,7 +4414,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *process* - keyword, text.text * _alf_explicit_auths.process_ - Process name explicitly allowed -* _unified_log.process_ - The name of the process that made the entry +* _unified_log.process_ - the name of the process that made the entry *process_being_tapped* - keyword, number.long @@ -4560,7 +4647,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *refs* - keyword, number.long -* _fbsd_kmods.refs_ - Module reverse dependencies * _kernel_extensions.refs_ - Reference count *region* - keyword, text.text @@ -4875,7 +4961,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *sender* - keyword, text.text * _asl.sender_ - Sender's identification string. Default is process name. -* _unified_log.sender_ - The name of the binary image that made the entry +* _unified_log.sender_ - the name of the binary image that made the entry *sensor_backend_server* - keyword, text.text @@ -5101,7 +5187,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _device_file.size_ - Size of file in bytes * _disk_events.size_ - Size of partition in bytes * _docker_image_history.size_ - Size of instruction in bytes -* _fbsd_kmods.size_ - Size of module content * _file.size_ - Size of file in bytes * _file_events.size_ - Size of file in bytes * _kernel_extensions.size_ - Bytes of wired memory used by extension @@ -5337,7 +5422,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *storage* - keyword, number.long -* _unified_log.storage_ - The storage category for the entry. +* _unified_log.storage_ - the storage category for the entry *storage_driver* - keyword, text.text @@ -5416,7 +5501,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *subsystem* - keyword, text.text * _system_controls.subsystem_ - Subsystem ID, control type -* _unified_log.subsystem_ - The subsystem of the os_log_t used +* _unified_log.subsystem_ - the subsystem of the os_log_t used *subsystem_model* - keyword, text.text @@ -5585,7 +5670,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _bpf_process_events.tid_ - Thread ID * _bpf_socket_events.tid_ - Thread ID -* _unified_log.tid_ - The tid of the thread that made the entry +* _unified_log.tid_ - the tid of the thread that made the entry * _windows_crashes.tid_ - Thread ID of the crashed thread * _windows_eventlog.tid_ - Thread ID which emitted the event record @@ -5637,7 +5722,7 @@ For more information about osquery tables, see the https://osquery.io/schema[osq *timestamp* - keyword, text.text * _time.timestamp_ - Current timestamp (log format) in UTC -* _unified_log.timestamp_ - Unix timestamp associated with the entry +* _unified_log.timestamp_ - unix timestamp associated with the entry * _windows_eventlog.timestamp_ - Timestamp to selectively filter the events *timestamp_ms* - keyword, number.long @@ -6078,7 +6163,6 @@ For more information about osquery tables, see the https://osquery.io/schema[osq * _osquery_packs.version_ - Minimum osquery version that this query will run on * _package_install_history.version_ - Package display version * _package_receipts.version_ - Installed package version -* _pkg_packages.version_ - Package version * _platform_info.version_ - Platform code version * _portage_keywords.version_ - The version which are affected by the use flags, empty means all * _portage_packages.version_ - The version which are affected by the use flags, empty means all diff --git a/package.json b/package.json index 5fbca346e2204..c2613ebda6e76 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "@dnd-kit/utilities": "^2.0.0", "@elastic/apm-rum": "^5.12.0", "@elastic/apm-rum-react": "^1.4.2", - "@elastic/charts": "51.3.0", + "@elastic/charts": "52.0.0", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.6.0-canary.3", "@elastic/ems-client": "8.4.0", @@ -109,9 +109,11 @@ "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", - "@elastic/react-search-ui": "^1.14.0", + "@elastic/react-search-ui": "^1.19.0", "@elastic/request-crypto": "2.0.1", - "@elastic/search-ui-app-search-connector": "^1.14.0", + "@elastic/search-ui": "^1.19.0", + "@elastic/search-ui-app-search-connector": "^1.19.0", + "@elastic/search-ui-engines-connector": "^1.19.0", "@emotion/cache": "^11.10.3", "@emotion/css": "^11.10.0", "@emotion/react": "^11.10.4", @@ -421,8 +423,8 @@ "@opentelemetry/semantic-conventions": "^1.4.0", "@reduxjs/toolkit": "1.7.2", "@slack/webhook": "^5.0.4", - "@tanstack/react-query": "^4.23.0", - "@tanstack/react-query-devtools": "^4.23.0", + "@tanstack/react-query": "^4.24.2", + "@tanstack/react-query-devtools": "^4.24.2", "@turf/along": "6.0.1", "@turf/area": "6.0.1", "@turf/bbox": "6.0.1", @@ -583,7 +585,7 @@ "react-fast-compare": "^2.0.4", "react-focus-on": "^3.7.0", "react-grid-layout": "^1.3.4", - "react-hook-form": "^7.42.1", + "react-hook-form": "^7.43.0", "react-intl": "^2.8.0", "react-is": "^17.0.2", "react-markdown": "^6.0.3", @@ -1174,7 +1176,7 @@ "svgo": "^2.8.0", "tape": "^5.0.1", "tempy": "^0.3.0", - "terser": "^5.15.1", + "terser": "^5.16.1", "terser-webpack-plugin": "^4.2.3", "tough-cookie": "^4.1.2", "tree-kill": "^1.2.2", diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts index 20ebeb234b479..76abc1b08bd84 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/aggs_types/bucket_aggs.ts @@ -117,7 +117,7 @@ const termsSchema = s.object({ exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), execution_hint: s.maybe(s.string()), - missing: s.maybe(s.number()), + missing: s.maybe(s.oneOf([s.number(), s.string(), s.boolean()])), min_doc_count: s.maybe(s.number({ min: 1 })), size: s.maybe(s.number()), show_term_doc_count_error: s.maybe(s.boolean()), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/validation.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/validation.test.ts index db50ab2b45d65..0120ecf75c797 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/validation.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/aggregations/validation.test.ts @@ -397,22 +397,6 @@ describe('validateAndConvertAggregations', () => { ); }); - it('throws an error when an attributes is not respecting its schema definition', () => { - const aggregations: AggsMap = { - someAgg: { - terms: { - missing: 'expecting a number', - }, - }, - }; - - expect(() => - validateAndConvertAggregations(['alert'], aggregations, mockMappings) - ).toThrowErrorMatchingInlineSnapshot( - `"[someAgg.terms.missing]: expected value of type [number] but got [string]"` - ); - }); - it('throws an error when trying to validate an unknown aggregation type', () => { const aggregations: AggsMap = { someAgg: { diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 0b253cedbc672..6b30af3f39097 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -135,6 +135,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { crawlerOverview: `${ENTERPRISE_SEARCH_DOCS}crawler.html`, deployTrainedModels: `${MACHINE_LEARNING_DOCS}ml-nlp-deploy-models.html`, documentLevelSecurity: `${ELASTICSEARCH_DOCS}document-level-security.html`, + engines: `${ENTERPRISE_SEARCH_DOCS}engines.html`, ingestPipelines: `${ENTERPRISE_SEARCH_DOCS}ingest-pipelines.html`, languageAnalyzers: `${ELASTICSEARCH_DOCS}analysis-lang-analyzer.html`, languageClients: `${ENTERPRISE_SEARCH_DOCS}programming-language-clients.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index dbfb37905172f..a6284078d3d8d 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -120,6 +120,7 @@ export interface DocLinks { readonly crawlerOverview: string; readonly deployTrainedModels: string; readonly documentLevelSecurity: string; + readonly engines: string; readonly ingestPipelines: string; readonly languageAnalyzers: string; readonly languageClients: string; diff --git a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx index 6066d7e81a58f..2043950a61d68 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/index.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/index.tsx @@ -27,6 +27,7 @@ export const FieldComponent: React.FC = ({ placeholder, selectedField, acceptsCustomOptions = false, + showMappingConflicts = false, }): JSX.Element => { const { isInvalid, @@ -44,6 +45,7 @@ export const FieldComponent: React.FC = ({ isRequired, selectedField, fieldInputWidth, + showMappingConflicts, onChange, }); @@ -68,6 +70,7 @@ export const FieldComponent: React.FC = ({ values: { searchValuePlaceholder: '{searchValue}' }, })} fullWidth + renderOption={renderFields} /> ); } diff --git a/packages/kbn-securitysolution-autocomplete/src/field/types.ts b/packages/kbn-securitysolution-autocomplete/src/field/types.ts index 35de595baef68..337ded6087a59 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/types.ts +++ b/packages/kbn-securitysolution-autocomplete/src/field/types.ts @@ -7,6 +7,7 @@ */ import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; +import { FieldConflictsInfo } from '@kbn/securitysolution-list-utils'; import { GetGenericComboBoxPropsReturn } from '../get_generic_combo_box_props'; export interface FieldProps extends FieldBaseProps { @@ -15,6 +16,7 @@ export interface FieldProps extends FieldBaseProps { isLoading: boolean; placeholder: string; acceptsCustomOptions?: boolean; + showMappingConflicts?: boolean; } export interface FieldBaseProps { indexPattern: DataViewBase | undefined; @@ -22,6 +24,7 @@ export interface FieldBaseProps { isRequired?: boolean; selectedField?: DataViewFieldBase | undefined; fieldInputWidth?: number; + showMappingConflicts?: boolean; onChange: (a: DataViewFieldBase[]) => void; } @@ -32,6 +35,7 @@ export interface ComboBoxFields { export interface GetFieldComboBoxPropsReturn extends GetGenericComboBoxPropsReturn { disabledLabelTooltipTexts: { [label: string]: string }; + mappingConflictsTooltipInfo: { [label: string]: FieldConflictsInfo[] }; } export interface DataViewField extends DataViewFieldBase { diff --git a/packages/kbn-securitysolution-autocomplete/src/field/use_field.tsx b/packages/kbn-securitysolution-autocomplete/src/field/use_field.tsx index e2396b93d251d..ce2d5b987ec16 100644 --- a/packages/kbn-securitysolution-autocomplete/src/field/use_field.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/use_field.tsx @@ -7,10 +7,18 @@ */ import React from 'react'; import { useCallback, useMemo, useState } from 'react'; -import { EuiComboBoxOptionOption, EuiToolTip } from '@elastic/eui'; +import { + EuiComboBoxOptionOption, + EuiIcon, + EuiSpacer, + EuiToolTip, + useEuiPaddingSize, +} from '@elastic/eui'; import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; +import { FieldConflictsInfo, getMappingConflictsInfo } from '@kbn/securitysolution-list-utils'; import { getGenericComboBoxProps } from '../get_generic_combo_box_props'; +import * as i18n from '../translations'; import { ComboBoxFields, DataViewField, @@ -72,6 +80,22 @@ const getDisabledLabelTooltipTexts = (fields: ComboBoxFields) => { ); return disabledLabelTooltipTexts; }; + +const getMappingConflictsTooltipInfo = (fields: ComboBoxFields) => { + const mappingConflictsTooltipInfo = fields.availableFields.reduce( + (acc: { [label: string]: FieldConflictsInfo[] }, field: DataViewField) => { + const conflictsInfo = getMappingConflictsInfo(field); + if (!conflictsInfo) { + return acc; + } + acc[field.name] = conflictsInfo; + return acc; + }, + {} + ); + return mappingConflictsTooltipInfo; +}; + const getComboBoxProps = (fields: ComboBoxFields): GetFieldComboBoxPropsReturn => { const { availableFields, selectedFields } = fields; @@ -81,9 +105,11 @@ const getComboBoxProps = (fields: ComboBoxFields): GetFieldComboBoxPropsReturn = selectedOptions: selectedFields, }); const disabledLabelTooltipTexts = getDisabledLabelTooltipTexts(fields); + const mappingConflictsTooltipInfo = getMappingConflictsTooltipInfo(fields); return { ...genericProps, disabledLabelTooltipTexts, + mappingConflictsTooltipInfo, }; }; @@ -93,11 +119,13 @@ export const useField = ({ isRequired, selectedField, fieldInputWidth, + showMappingConflicts, onChange, }: FieldBaseProps) => { const [touched, setIsTouched] = useState(false); const [customOption, setCustomOption] = useState(null); + const sPaddingSize = useEuiPaddingSize('s'); const { availableFields, selectedFields } = useMemo(() => { const indexPatternsToUse = @@ -107,7 +135,13 @@ export const useField = ({ return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter); }, [indexPattern, fieldTypeFilter, selectedField, customOption]); - const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo( + const { + comboOptions, + labels, + selectedComboOptions, + disabledLabelTooltipTexts, + mappingConflictsTooltipInfo, + } = useMemo( () => getComboBoxProps({ availableFields, selectedFields }), [availableFields, selectedFields] ); @@ -168,6 +202,46 @@ export const useField = ({ ); } + + const conflictsInfo = mappingConflictsTooltipInfo[label]; + if (showMappingConflicts && conflictsInfo) { + const tooltipContent = ( + <> + {i18n.FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION} + {conflictsInfo.map((info) => { + const groupDetails = info.groupedIndices.map( + ({ name, count }) => + `${count > 1 ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(name, count) : name}` + ); + return ( + <> + + {`${ + info.totalIndexCount > 1 + ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(info.type, info.totalIndexCount) + : info.type + }: ${groupDetails.join(', ')}`} + + ); + })} + + ); + return ( + + <> + {label} + + + + ); + } + return label; }; return { diff --git a/packages/kbn-securitysolution-autocomplete/src/translations/index.ts b/packages/kbn-securitysolution-autocomplete/src/translations/index.ts index df54ada4fb205..5267e213fa5c3 100644 --- a/packages/kbn-securitysolution-autocomplete/src/translations/index.ts +++ b/packages/kbn-securitysolution-autocomplete/src/translations/index.ts @@ -43,6 +43,26 @@ export const SEE_DOCUMENTATION = i18n.translate('autocomplete.seeDocumentation', defaultMessage: 'See Documentation', }); +export const FIELD_CONFLICT_INDICES_WARNING_TITLE = i18n.translate( + 'autocomplete.conflictIndicesWarning.title', + { + defaultMessage: 'Mapping Conflict', + } +); + +export const FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION = i18n.translate( + 'autocomplete.conflictIndicesWarning.description', + { + defaultMessage: 'This field is defined as several types across different indices.', + } +); + +export const CONFLICT_MULTIPLE_INDEX_DESCRIPTION = (name: string, count: number): string => + i18n.translate('autocomplete.conflictIndicesWarning.index.description', { + defaultMessage: '{name} ({count} indices)', + values: { count, name }, + }); + // eslint-disable-next-line import/no-default-export export default { LOADING, diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts index d2f84b4ee3708..ab1f35c54b5db 100644 --- a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.test.ts @@ -16,6 +16,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: true, overwrite_exceptions: true, + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -30,6 +31,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: 'wrong', overwrite_exceptions: true, + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -48,6 +50,7 @@ describe('importQuerySchema', () => { as_new_list: false, overwrite: true, overwrite_exceptions: 'wrong', + overwrite_action_connectors: true, }; const decoded = importQuerySchema.decode(payload); const checked = exactCheck(payload, decoded); @@ -58,6 +61,24 @@ describe('importQuerySchema', () => { ]); expect(message.schema).toEqual({}); }); + test('it should NOT validate a non boolean value for "overwrite_action_connectors"', () => { + const payload: Omit & { + overwrite_action_connectors: string; + } = { + as_new_list: false, + overwrite: true, + overwrite_exceptions: true, + overwrite_action_connectors: 'wrong', + }; + const decoded = importQuerySchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "wrong" supplied to "overwrite_action_connectors"', + ]); + expect(message.schema).toEqual({}); + }); test('it should NOT allow an extra key to be sent in', () => { const payload: ImportQuerySchema & { diff --git a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts index ca0e35c4aa176..5b1ee38bb0855 100644 --- a/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts +++ b/packages/kbn-securitysolution-io-ts-types/src/import_query_schema/index.ts @@ -14,6 +14,7 @@ export const importQuerySchema = t.exact( t.partial({ overwrite: DefaultStringBooleanFalse, overwrite_exceptions: DefaultStringBooleanFalse, + overwrite_action_connectors: DefaultStringBooleanFalse, as_new_list: DefaultStringBooleanFalse, }) ); @@ -21,9 +22,10 @@ export const importQuerySchema = t.exact( export type ImportQuerySchema = t.TypeOf; export type ImportQuerySchemaDecoded = Omit< ImportQuerySchema, - 'overwrite' | 'overwrite_exceptions' | 'as_new_list' + 'overwrite' | 'overwrite_exceptions' | 'as_new_list' | 'overwrite_action_connectors' > & { overwrite: boolean; overwrite_exceptions: boolean; + overwrite_action_connectors: boolean; as_new_list: boolean; }; diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.test.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.test.ts new file mode 100644 index 0000000000000..31a5ba496647f --- /dev/null +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { getMappingConflictsInfo } from '.'; + +describe('Helpers', () => { + describe('getMappingConflictsInfo', () => { + test('it return null if there are not conflicts', () => { + const field = { + name: 'field1', + type: 'string', + }; + const conflictsInfo = getMappingConflictsInfo(field); + + expect(conflictsInfo).toBeNull(); + }); + test('it groups ".ds-" data stream indices', () => { + const field = { + name: 'field1', + type: 'conflict', + conflictDescriptions: { + text: [ + '.ds-logs-default-2023.01.18-000001', + '.ds-logs-default-2023.01.18-000002', + '.ds-logs-tortilla.process-default-2022.11.20-000011', + '.ds-logs-tortilla.process-default-2022.11.20-000012', + '.ds-logs-tortilla.process-default-2022.11.20-000016', + ], + long: [ + '.ds-logs-default-2023.01.18-000004', + '.ds-logs-default-2023.01.18-000005', + 'partial-.ds-logs-gcp.audit-2021.12.22-000240', + 'partial-.ds-logs-gcp.audit-2021.12.22-000242', + ], + }, + }; + const conflictsInfo = getMappingConflictsInfo(field); + + expect(conflictsInfo).toEqual([ + { + type: 'text', + totalIndexCount: 5, + groupedIndices: [ + { name: 'logs-tortilla.process-default', count: 3 }, + { name: 'logs-default', count: 2 }, + ], + }, + { + type: 'long', + totalIndexCount: 4, + groupedIndices: [ + { name: 'logs-default', count: 2 }, + { name: 'logs-gcp.audit', count: 2 }, + ], + }, + ]); + }); + test('it groups old ".siem-" indices', () => { + const field = { + name: 'field1', + type: 'conflict', + conflictDescriptions: { + text: [ + '.siem-signals-default-000001', + '.siem-signals-default-000002', + '.siem-signals-default-000011', + '.siem-signals-default-000012', + ], + unmapped: [ + '.siem-signals-default-000004', + '.siem-signals-default-000005', + '.siem-signals-default-000240', + ], + }, + }; + const conflictsInfo = getMappingConflictsInfo(field); + + expect(conflictsInfo).toEqual([ + { + type: 'text', + totalIndexCount: 4, + groupedIndices: [{ name: '.siem-signals-default', count: 4 }], + }, + { + type: 'unmapped', + totalIndexCount: 3, + groupedIndices: [{ name: '.siem-signals-default', count: 3 }], + }, + ]); + }); + test('it groups mixed indices', () => { + const field = { + name: 'field1', + type: 'conflict', + conflictDescriptions: { + boolean: [ + '.ds-logs-default-2023.01.18-000001', + '.ds-logs-tortilla.process-default-2022.11.20-000011', + '.ds-logs-tortilla.process-default-2022.11.20-000012', + '.ds-logs-tortilla.process-default-2022.11.20-000016', + '.siem-signals-default-000001', + '.siem-signals-default-000002', + '.siem-signals-default-000012', + 'my-own-index-1', + 'my-own-index-2', + ], + unmapped: [ + '.siem-signals-default-000004', + 'partial-.ds-logs-gcp.audit-2021.12.22-000240', + 'partial-.ds-logs-gcp.audit-2021.12.22-000242', + 'my-own-index-3', + ], + }, + }; + const conflictsInfo = getMappingConflictsInfo(field); + + expect(conflictsInfo).toEqual([ + { + type: 'boolean', + totalIndexCount: 9, + groupedIndices: [ + { name: 'logs-tortilla.process-default', count: 3 }, + { name: '.siem-signals-default', count: 3 }, + { name: 'logs-default', count: 1 }, + { name: 'my-own-index-1', count: 1 }, + { name: 'my-own-index-2', count: 1 }, + ], + }, + { + type: 'unmapped', + totalIndexCount: 4, + groupedIndices: [ + { name: 'logs-gcp.audit', count: 2 }, + { name: '.siem-signals-default', count: 1 }, + { name: 'my-own-index-3', count: 1 }, + ], + }, + ]); + }); + }); +}); diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 638dead59e366..38a41393b867c 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -53,6 +53,7 @@ import { import { BuilderEntry, CreateExceptionListItemBuilderSchema, + DataViewField, EmptyEntry, EmptyNestedEntry, ExceptionsBuilderExceptionItem, @@ -912,3 +913,87 @@ export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ export const containsValueListEntry = (items: ExceptionsBuilderExceptionItem[]): boolean => items.some((item) => item.entries.some(({ type }) => type === OperatorTypeEnum.LIST)); + +const getIndexGroupName = (indexName: string): string => { + // Check whether it is a Data Stream index + const dataStreamExp = /.ds-(.*?)-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[0-9]{6}/; + let result = indexName.match(dataStreamExp); + if (result && result.length === 2) { + return result[1]; + } + + // Check whether it is an old '.siem' index group + const siemSignalsExp = /.siem-(.*?)-[0-9]{6}/; + result = indexName.match(siemSignalsExp); + if (result && result.length === 2) { + return `.siem-${result[1]}`; + } + + // Otherwise return index name + return indexName; +}; + +export interface FieldConflictsInfo { + /** + * Kibana field type + */ + type: string; + /** + * Total count of the indices of this type + */ + totalIndexCount: number; + /** + * Grouped indices info + */ + groupedIndices: Array<{ + /** + * Index group name (like '.ds-...' or '.siem-signals-...') + */ + name: string; + /** + * Count of indices in the group + */ + count: number; + }>; +} + +export const getMappingConflictsInfo = (field: DataViewField): FieldConflictsInfo[] | null => { + if (!field.conflictDescriptions) { + return null; + } + const conflicts: FieldConflictsInfo[] = []; + for (const [key, value] of Object.entries(field.conflictDescriptions)) { + const groupedIndices: Array<{ + name: string; + count: number; + }> = []; + + // Group indices and calculate count of indices in each group + const groupedInfo: { [key: string]: number } = {}; + value.forEach((index) => { + const groupName = getIndexGroupName(index); + if (!groupedInfo[groupName]) { + groupedInfo[groupName] = 0; + } + groupedInfo[groupName]++; + }); + for (const [name, count] of Object.entries(groupedInfo)) { + groupedIndices.push({ + name, + count, + }); + } + + // Sort groups by the indices count + groupedIndices.sort((group1, group2) => { + return group2.count - group1.count; + }); + + conflicts.push({ + type: key, + totalIndexCount: value.length, + groupedIndices, + }); + } + return conflicts; +}; diff --git a/packages/kbn-securitysolution-list-utils/src/types/index.ts b/packages/kbn-securitysolution-list-utils/src/types/index.ts index 13b62fac657a2..ccd0ab68799bb 100644 --- a/packages/kbn-securitysolution-list-utils/src/types/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/types/index.ts @@ -117,3 +117,7 @@ export const exceptionListAgnosticSavedObjectType = EXCEPTION_LIST_NAMESPACE_AGN export type SavedObjectType = | typeof EXCEPTION_LIST_NAMESPACE | typeof EXCEPTION_LIST_NAMESPACE_AGNOSTIC; + +export interface DataViewField extends DataViewFieldBase { + conflictDescriptions?: Record; +} diff --git a/packages/shared-ux/prompt/not_found/README.mdx b/packages/shared-ux/prompt/not_found/README.mdx index dc784f86b3854..3db21640d5760 100644 --- a/packages/shared-ux/prompt/not_found/README.mdx +++ b/packages/shared-ux/prompt/not_found/README.mdx @@ -10,3 +10,27 @@ date: 2022-02-09 Sometimes the user tries to go to a page that doesn't exist, because the URL is broken or because they try to load a resource that does not exist. For those cases we want to show a standard 404 error. The default call to action is a "Go back" button that simulates the browser's "Back" behaviour. Consumers can specify their own CTA's with the `actions` prop. + +The NotFoundPrompt component also has the prop `actions` that takes in an array of components. +For example: + +```jsx + + + + Go home + , + + Go to discover + + ] + } + title="Customizable Title" + body="Customizable Body" + /> + + +``` \ No newline at end of file diff --git a/packages/shared-ux/prompt/not_found/src/not_found_prompt.stories.tsx b/packages/shared-ux/prompt/not_found/src/not_found_prompt.stories.tsx index 4cc103140929e..f5dccc68d9c97 100644 --- a/packages/shared-ux/prompt/not_found/src/not_found_prompt.stories.tsx +++ b/packages/shared-ux/prompt/not_found/src/not_found_prompt.stories.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiButton, EuiButtonEmpty, EuiPageTemplate } from '@elastic/eui'; +import { EuiButton, EuiPageTemplate } from '@elastic/eui'; import React from 'react'; import { Meta, Story } from '@storybook/react'; import mdx from '../README.mdx'; @@ -51,14 +51,18 @@ export const CustomActions: Story = (args) => { - Go home - , - - Go to discover - , - ]} + actions={ + <> + + Go home + + + Go to discover + + + } + title="Customizable Title" + body="Customizable Body" /> diff --git a/packages/shared-ux/prompt/not_found/src/not_found_prompt.test.tsx b/packages/shared-ux/prompt/not_found/src/not_found_prompt.test.tsx index 48f0d82520f30..da836d48f9266 100644 --- a/packages/shared-ux/prompt/not_found/src/not_found_prompt.test.tsx +++ b/packages/shared-ux/prompt/not_found/src/not_found_prompt.test.tsx @@ -36,4 +36,11 @@ describe('', () => { const component = render(); expect(component.text()).toContain('I am a button'); }); + + it('Renders custom actions with an array with multiple buttons', () => { + const actions = [, ]; + const component = render(); + expect(component.text()).toContain('I am a button'); + expect(component.text()).toContain('I am a second button'); + }); }); diff --git a/renovate.json b/renovate.json index 932f0a6558864..ce12b9dd5a248 100644 --- a/renovate.json +++ b/renovate.json @@ -153,6 +153,17 @@ "labels": ["Team:Operations", "release_note:skip", "backport:all-open"], "enabled": true }, + { + "groupName": "minify", + "packageNames": [ + "gulp-terser", + "terser" + ], + "reviewers": ["team:kibana-operations"], + "matchBaseBranches": ["main"], + "labels": ["Team:Operations", "release_note:skip"], + "enabled": true + }, { "groupName": "@testing-library", "packageNames": [ diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index 5ae02ab100926..0357fb698318a 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -74,7 +74,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cases-configure": "1afc414f5563a36e4612fa269193d3ed7277c7bd", "cases-connector-mappings": "4b16d440af966e5d6e0fa33368bfa15d987a4b69", "cases-telemetry": "16e261e7378a72acd0806f18df92525dd1da4f37", - "cases-user-actions": "f1b0dcfeb58a65e68b35c5e99ddee70e746a06c7", + "cases-user-actions": "9570938b5832f0e91ca489d8e1edf7dcaef59e55", "config": "95d8ecc452186201a691e72ec154ba65dfc5cb98", "config-global": "b8f559884931609a349e129c717af73d23e7bc76", "connector_token": "fa5301aa5a2914795d3b1b82d0a49939444009da", diff --git a/src/plugins/charts/public/static/components/endzones.tsx b/src/plugins/charts/public/static/components/endzones.tsx index e89ec60fd5a8b..fed388c5123f6 100644 --- a/src/plugins/charts/public/static/components/endzones.tsx +++ b/src/plugins/charts/public/static/components/endzones.tsx @@ -14,6 +14,7 @@ import { RectAnnotation, RectAnnotationDatum, RectAnnotationStyle, + AnnotationTooltipFormatter, } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, useEuiTheme } from '@elastic/eui'; @@ -142,7 +143,7 @@ const partialDataText = i18n.translate('charts.partialData.bucketTooltipText', { 'The selected time range does not include this entire bucket. It might contain partial data.', }); -const Prompt = () => { +const Prompt: AnnotationTooltipFormatter = () => { const { euiTheme } = useEuiTheme(); const headerPartialCss = css` font-weight: ${euiTheme.font.weight.regular}; diff --git a/src/plugins/console/common/constants/welcome_panel.ts b/src/plugins/console/common/constants/welcome_panel.ts new file mode 100644 index 0000000000000..dd5c17d8c9f8e --- /dev/null +++ b/src/plugins/console/common/constants/welcome_panel.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const kibanaApiExample = ` +# retrieve sets of saved objects +POST kbn:api/saved_objects/_export +`; + +export const multipleRequestsExample = ` +# POST test_index/_doc/test_doc 200 OK +{ + "_index": "test_index", + "_id": "test_doc", + "_version": 3, + "result": "updated", + "_shards": { + "total": 2, + "successful": 1, + "failed": 0 + }, + "_seq_no": 2, + "_primary_term": 1 +} +# POST notAnEndpoint 405 Method Not Allowed +{ + "error": "Incorrect HTTP method for uri [/notAnEndpoint?pretty=true] and method [POST], allowed: [HEAD, GET, PUT, DELETE]", + "status": 405 +} +`; + +export const commentsExample = ` +# This request searches all of your indices. +GET _search +{ + // The query parameter indicates query context. + "query": { + // Matches all documents. + /*"match_all": { + "boost": 1.2 + }*/ + "match_none": {} // Matches no document. + } +} +`; + +export const variablesExample = ` +GET $\{pathVariable} +{ + "query": { + "match": { + "$\{bodyNameVariable}": "$\{bodyValueVariable}" + } + } +} +`; diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index 21e3ab0c7d274..4c456a2de44c1 100644 --- a/src/plugins/console/public/application/components/editor_example.tsx +++ b/src/plugins/console/public/application/components/editor_example.tsx @@ -6,30 +6,40 @@ * Side Public License, v 1. */ -import { EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiScreenReaderOnly, withEuiTheme } from '@elastic/eui'; +import type { WithEuiThemeProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useEffect, useRef } from 'react'; import { createReadOnlyAceEditor, CustomAceEditor } from '../models/sense_editor'; // @ts-ignore -import { Mode } from '../models/legacy_core_editor/mode/input'; +import { Mode as InputMode } from '../models/legacy_core_editor/mode/input'; +import { Mode as OutputMode } from '../models/legacy_core_editor/mode/output'; interface EditorExampleProps { panel: string; + example?: string; + theme: WithEuiThemeProps['theme']; + linesOfExampleCode?: number; + mode?: string; } const exampleText = ` -# index a doc -PUT index/_doc/1 +GET _search { - "body": "here" + "query": { + "match_all": {} + } } - -# and get it ... -GET index/_doc/1 `; -export function EditorExample(props: EditorExampleProps) { - const inputId = `help-example-${props.panel}-input`; +const EditorExample = ({ + panel, + example, + theme, + linesOfExampleCode = 6, + mode = 'input', +}: EditorExampleProps) => { + const inputId = `help-example-${panel}-input`; const wrapperDivRef = useRef(null); const editorRef = useRef(); @@ -38,8 +48,8 @@ export function EditorExample(props: EditorExampleProps) { editorRef.current = createReadOnlyAceEditor(wrapperDivRef.current); const editor = editorRef.current; - editor.update(exampleText.trim()); - editor.session.setMode(new Mode()); + const editorMode = mode === 'input' ? new InputMode() : new OutputMode(); + editor.update((example || exampleText).trim(), editorMode); editor.session.setUseWorker(false); editor.setHighlightActiveLine(false); @@ -55,7 +65,12 @@ export function EditorExample(props: EditorExampleProps) { editorRef.current.destroy(); } }; - }, [inputId]); + }, [example, inputId, mode]); + + const wrapperDivStyle = { + height: `${parseInt(theme.euiTheme.size.base, 10) * linesOfExampleCode}px`, + margin: `${theme.euiTheme.size.base} 0`, + }; return ( <> @@ -66,7 +81,10 @@ export function EditorExample(props: EditorExampleProps) { })} -
+
); -} +}; + +// eslint-disable-next-line import/no-default-export +export default withEuiTheme(EditorExample); diff --git a/src/plugins/console/public/application/components/help_panel.tsx b/src/plugins/console/public/application/components/help_panel.tsx index ed57d3178df4b..a939899af1879 100644 --- a/src/plugins/console/public/application/components/help_panel.tsx +++ b/src/plugins/console/public/application/components/help_panel.tsx @@ -17,7 +17,7 @@ import { EuiSpacer, EuiLink, } from '@elastic/eui'; -import { EditorExample } from './editor_example'; +import EditorExample from './editor_example'; import { useServicesContext } from '../contexts'; interface Props { diff --git a/src/plugins/console/public/application/components/welcome_panel.tsx b/src/plugins/console/public/application/components/welcome_panel.tsx index 3a7898df084a7..9b2741e4c30e0 100644 --- a/src/plugins/console/public/application/components/welcome_panel.tsx +++ b/src/plugins/console/public/application/components/welcome_panel.tsx @@ -18,8 +18,10 @@ import { EuiButton, EuiText, EuiFlyoutFooter, + EuiCode, } from '@elastic/eui'; -import { EditorExample } from './editor_example'; +import EditorExample from './editor_example'; +import * as examples from '../../../common/constants/welcome_panel'; interface Props { onDismiss: () => void; @@ -27,85 +29,127 @@ interface Props { export function WelcomePanel(props: Props) { return ( - +

-

+

-

+

+

kbn:, + }} />

+ +

+ +

- + + +

+ +

#, + doubleSlash: //, + slashAsterisk: /*, + asteriskSlash: */, + }} />

+

-
    -
  • - -
  • -
  • - -
  • -
  • - -
  • +

    + ${variableName}, + }} + /> +

    + +
    1. Variables, + }} />
    2. -
+ +
diff --git a/src/plugins/console/public/application/containers/editor/editor.tsx b/src/plugins/console/public/application/containers/editor/editor.tsx index 93c11aa53c602..788039a2dc606 100644 --- a/src/plugins/console/public/application/containers/editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/editor.tsx @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import React, { useCallback, memo } from 'react'; +import React, { useCallback, memo, useEffect, useState } from 'react'; import { debounce } from 'lodash'; import { EuiProgress } from '@elastic/eui'; import { EditorContentSpinner } from '../../components'; import { Panel, PanelsContainer } from '..'; import { Editor as EditorUI, EditorOutput } from './legacy/console_editor'; -import { StorageKeys } from '../../../services'; +import { getAutocompleteInfo, StorageKeys } from '../../../services'; import { useEditorReadContext, useServicesContext, useRequestReadContext } from '../../contexts'; import type { SenseEditor } from '../../models'; @@ -33,6 +33,15 @@ export const Editor = memo(({ loading, setEditorInstance }: Props) => { const { currentTextObject } = useEditorReadContext(); const { requestInFlight } = useRequestReadContext(); + const [fetchingMappings, setFetchingMappings] = useState(false); + + useEffect(() => { + const subscription = getAutocompleteInfo().mapping.isLoading$.subscribe(setFetchingMappings); + return () => { + subscription.unsubscribe(); + }; + }, []); + const [firstPanelWidth, secondPanelWidth] = storage.get(StorageKeys.WIDTH, [ INITIAL_PANEL_WIDTH, INITIAL_PANEL_WIDTH, @@ -50,7 +59,7 @@ export const Editor = memo(({ loading, setEditorInstance }: Props) => { return ( <> - {requestInFlight ? ( + {requestInFlight || fetchingMappings ? (
diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 1cf9a54210973..92d875b9db2a9 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -69,6 +69,8 @@ export function renderApp({ const api = createApi({ http }); const esHostService = createEsHostService({ api }); + autocompleteInfo.mapping.setup(http, settings); + render( diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 5def8a696df2f..5c041a95e216f 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import ace from 'brace'; +import ace, { type Annotation } from 'brace'; import { Editor as IAceEditor, IEditSession as IAceEditSession } from 'brace'; import $ from 'jquery'; import { @@ -402,8 +402,7 @@ export class LegacyCoreEditor implements CoreEditor { getCompletions: ( // eslint-disable-next-line @typescript-eslint/naming-convention DO_NOT_USE_1: IAceEditor, - // eslint-disable-next-line @typescript-eslint/naming-convention - DO_NOT_USE_2: IAceEditSession, + aceEditSession: IAceEditSession, pos: { row: number; column: number }, prefix: string, callback: (...args: unknown[]) => void @@ -412,7 +411,30 @@ export class LegacyCoreEditor implements CoreEditor { lineNumber: pos.row + 1, column: pos.column + 1, }; - autocompleter(position, prefix, callback); + + const getAnnotationControls = () => { + let customAnnotation: Annotation; + return { + setAnnotation(text: string) { + const annotations = aceEditSession.getAnnotations(); + customAnnotation = { + text, + row: pos.row, + column: pos.column, + type: 'warning', + }; + + aceEditSession.setAnnotations([...annotations, customAnnotation]); + }, + removeAnnotation() { + aceEditSession.setAnnotations( + aceEditSession.getAnnotations().filter((a: Annotation) => a !== customAnnotation) + ); + }, + }; + }; + + autocompleter(position, prefix, callback, getAnnotationControls()); }, }, ]); diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index 434deb5456caf..08912871052ba 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -13,6 +13,9 @@ import $ from 'jquery'; import * as kb from '../../../lib/kb/kb'; import { AutocompleteInfo, setAutocompleteInfo } from '../../../services'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { StorageMock } from '../../../services/storage.mock'; +import { SettingsMock } from '../../../services/settings.mock'; describe('Integration', () => { let senseEditor; @@ -27,6 +30,15 @@ describe('Integration', () => { $(senseEditor.getCoreEditor().getContainer()).show(); senseEditor.autocomplete._test.removeChangeListener(); autocompleteInfo = new AutocompleteInfo(); + + const httpMock = httpServiceMock.createSetupContract(); + const storage = new StorageMock({}, 'test'); + const settingsMock = new SettingsMock(storage); + + settingsMock.getAutocomplete.mockReturnValue({ fields: true }); + + autocompleteInfo.mapping.setup(httpMock, settingsMock); + setAutocompleteInfo(autocompleteInfo); }); afterEach(() => { @@ -164,7 +176,8 @@ describe('Integration', () => { ac('textBoxPosition', posCompare); ac('rangeToReplace', rangeCompare); done(); - } + }, + { setAnnotation: () => {}, removeAnnotation: () => {} } ); }); }); diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index 4e3770779f580..b24767b361d7e 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -593,7 +593,10 @@ export default function ({ return null; } - if (!context.autoCompleteSet) { + const isMappingsFetchingInProgress = + context.autoCompleteType === 'body' && !!context.asyncResultsState?.isLoading; + + if (!context.autoCompleteSet && !isMappingsFetchingInProgress) { return null; // nothing to do.. } @@ -1123,80 +1126,112 @@ export default function ({ } } + /** + * Extracts terms from the autocomplete set. + * @param context + */ + function getTerms(context: AutoCompleteContext, autoCompleteSet: ResultTerm[]) { + const terms = _.map( + autoCompleteSet.filter((term) => Boolean(term) && term.name != null), + function (term) { + if (typeof term !== 'object') { + term = { + name: term, + }; + } else { + term = _.clone(term); + } + const defaults: { + value?: string; + meta: string; + score: number; + context: AutoCompleteContext; + completer?: { insertMatch: (v: unknown) => void }; + } = { + value: term.name, + meta: 'API', + score: 0, + context, + }; + // we only need our custom insertMatch behavior for the body + if (context.autoCompleteType === 'body') { + defaults.completer = { + insertMatch() { + return applyTerm(term); + }, + }; + } + return _.defaults(term, defaults); + } + ); + + terms.sort(function ( + t1: { score: number; name?: string }, + t2: { score: number; name?: string } + ) { + /* score sorts from high to low */ + if (t1.score > t2.score) { + return -1; + } + if (t1.score < t2.score) { + return 1; + } + /* names sort from low to high */ + if (t1.name! < t2.name!) { + return -1; + } + if (t1.name === t2.name) { + return 0; + } + return 1; + }); + + return terms; + } + + function getSuggestions(terms: ResultTerm[]) { + return _.map(terms, function (t, i) { + t.insertValue = t.insertValue || t.value; + t.value = '' + t.value; // normalize to strings + t.score = -i; + return t; + }); + } + function getCompletions( position: Position, prefix: string, - callback: (e: Error | null, result: ResultTerm[] | null) => void + callback: (e: Error | null, result: ResultTerm[] | null) => void, + annotationControls: { + setAnnotation: (text: string) => void; + removeAnnotation: () => void; + } ) { try { const context = getAutoCompleteContext(editor, position); + if (!context) { callback(null, []); } else { - const terms = _.map( - context.autoCompleteSet!.filter((term) => Boolean(term) && term.name != null), - function (term) { - if (typeof term !== 'object') { - term = { - name: term, - }; - } else { - term = _.clone(term); - } - const defaults: { - value?: string; - meta: string; - score: number; - context: AutoCompleteContext; - completer?: { insertMatch: (v: unknown) => void }; - } = { - value: term.name, - meta: 'API', - score: 0, - context, - }; - // we only need our custom insertMatch behavior for the body - if (context.autoCompleteType === 'body') { - defaults.completer = { - insertMatch() { - return applyTerm(term); - }, - }; - } - return _.defaults(term, defaults); - } - ); - - terms.sort(function ( - t1: { score: number; name?: string }, - t2: { score: number; name?: string } - ) { - /* score sorts from high to low */ - if (t1.score > t2.score) { - return -1; - } - if (t1.score < t2.score) { - return 1; - } - /* names sort from low to high */ - if (t1.name! < t2.name!) { - return -1; - } - if (t1.name === t2.name) { - return 0; - } - return 1; - }); - - callback( - null, - _.map(terms, function (t, i) { - t.insertValue = t.insertValue || t.value; - t.value = '' + t.value; // normalize to strings - t.score = -i; - return t; - }) - ); + if (!context.asyncResultsState?.isLoading) { + const terms = getTerms(context, context.autoCompleteSet!); + const suggestions = getSuggestions(terms); + callback(null, suggestions); + } + + if (context.asyncResultsState) { + annotationControls.setAnnotation( + i18n.translate('console.autocomplete.fieldsFetchingAnnotation', { + defaultMessage: 'Fields fetching is in progress', + }) + ); + + context.asyncResultsState.results.then((r) => { + const asyncSuggestions = getSuggestions(getTerms(context, r)); + callback(null, asyncSuggestions); + annotationControls.removeAnnotation(); + }); + } } } catch (e) { // eslint-disable-next-line no-console @@ -1216,8 +1251,12 @@ export default function ({ _editSession: unknown, pos: Position, prefix: string, - callback: (e: Error | null, result: ResultTerm[] | null) => void - ) => getCompletions(pos, prefix, callback), + callback: (e: Error | null, result: ResultTerm[] | null) => void, + annotationControls: { + setAnnotation: (text: string) => void; + removeAnnotation: () => void; + } + ) => getCompletions(pos, prefix, callback, annotationControls), addReplacementInfoToContext, addChangeListener: () => editor.on('changeSelection', editorChangeListener), removeChangeListener: () => editor.off('changeSelection', editorChangeListener), diff --git a/src/plugins/console/public/lib/autocomplete/types.ts b/src/plugins/console/public/lib/autocomplete/types.ts index 15d32e6426a6c..a151c13f46c20 100644 --- a/src/plugins/console/public/lib/autocomplete/types.ts +++ b/src/plugins/console/public/lib/autocomplete/types.ts @@ -13,6 +13,7 @@ export interface ResultTerm { insertValue?: string; name?: string; value?: string; + score?: number; } export interface DataAutoCompleteRulesOneOf { @@ -25,6 +26,14 @@ export interface DataAutoCompleteRulesOneOf { export interface AutoCompleteContext { autoCompleteSet?: null | ResultTerm[]; + /** + * Stores a state for async results, e.g. fields suggestions based on the mappings definition. + */ + asyncResultsState?: { + isLoading: boolean; + lastFetched: number | null; + results: Promise; + }; endpoint?: null | { paramsAutocomplete: { getTopLevelComponents: (method?: string | null) => unknown; diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js index 5349538799d9b..f856ef5750a17 100644 --- a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js +++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js @@ -9,6 +9,9 @@ import '../../application/models/sense_editor/sense_editor.test.mocks'; import { setAutocompleteInfo, AutocompleteInfo } from '../../services'; import { expandAliases } from './expand_aliases'; +import { httpServiceMock } from '@kbn/core-http-browser-mocks'; +import { SettingsMock } from '../../services/settings.mock'; +import { StorageMock } from '../../services/storage.mock'; function fc(f1, f2) { if (f1.name < f2.name) { @@ -32,10 +35,20 @@ describe('Autocomplete entities', () => { let componentTemplate; let dataStream; let autocompleteInfo; + let settingsMock; + let httpMock; + beforeEach(() => { autocompleteInfo = new AutocompleteInfo(); setAutocompleteInfo(autocompleteInfo); mapping = autocompleteInfo.mapping; + + httpMock = httpServiceMock.createSetupContract(); + const storage = new StorageMock({}, 'test'); + settingsMock = new SettingsMock(storage); + + mapping.setup(httpMock, settingsMock); + alias = autocompleteInfo.alias; legacyTemplate = autocompleteInfo.legacyTemplate; indexTemplate = autocompleteInfo.indexTemplate; @@ -48,61 +61,98 @@ describe('Autocomplete entities', () => { }); describe('Mappings', function () { - test('Multi fields 1.0 style', function () { - mapping.loadMappings({ - index: { - properties: { - first_name: { - type: 'string', - index: 'analyzed', - path: 'just_name', - fields: { - any_name: { type: 'string', index: 'analyzed' }, - }, - }, - last_name: { - type: 'string', - index: 'no', - fields: { - raw: { type: 'string', index: 'analyzed' }, + describe('When fields autocomplete is disabled', () => { + beforeEach(() => { + settingsMock.getAutocomplete.mockReturnValue({ fields: false }); + }); + + test('does not return any suggestions', function () { + mapping.loadMappings({ + index: { + properties: { + first_name: { + type: 'string', + index: 'analyzed', + path: 'just_name', + fields: { + any_name: { type: 'string', index: 'analyzed' }, + }, }, }, }, - }, - }); + }); - expect(mapping.getMappings('index').sort(fc)).toEqual([ - f('any_name', 'string'), - f('first_name', 'string'), - f('last_name', 'string'), - f('last_name.raw', 'string'), - ]); + expect(mapping.getMappings('index').sort(fc)).toEqual([]); + }); }); - test('Simple fields', function () { - mapping.loadMappings({ - index: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', + describe('When fields autocomplete is enabled', () => { + beforeEach(() => { + settingsMock.getAutocomplete.mockReturnValue({ fields: true }); + httpMock.get.mockReturnValue( + Promise.resolve({ + mappings: { index: { mappings: { properties: { '@timestamp': { type: 'date' } } } } }, + }) + ); + }); + + test('attempts to fetch mappings if not loaded', async () => { + const autoCompleteContext = {}; + let loadingIndicator; + + mapping.isLoading$.subscribe((v) => { + loadingIndicator = v; + }); + + // act + mapping.getMappings('index', [], autoCompleteContext); + + expect(autoCompleteContext.asyncResultsState.isLoading).toBe(true); + expect(loadingIndicator).toBe(true); + + expect(httpMock.get).toHaveBeenCalled(); + + const fields = await autoCompleteContext.asyncResultsState.results; + + expect(loadingIndicator).toBe(false); + expect(autoCompleteContext.asyncResultsState.isLoading).toBe(false); + expect(fields).toEqual([{ name: '@timestamp', type: 'date' }]); + }); + + test('Multi fields 1.0 style', function () { + mapping.loadMappings({ + index: { + properties: { + first_name: { + type: 'string', + index: 'analyzed', + path: 'just_name', + fields: { + any_name: { type: 'string', index: 'analyzed' }, + }, + }, + last_name: { + type: 'string', + index: 'no', + fields: { + raw: { type: 'string', index: 'analyzed' }, + }, + }, }, }, - }, - }); + }); - expect(mapping.getMappings('index').sort(fc)).toEqual([ - f('number', 'int'), - f('str', 'string'), - ]); - }); + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('any_name', 'string'), + f('first_name', 'string'), + f('last_name', 'string'), + f('last_name.raw', 'string'), + ]); + }); - test('Simple fields - 1.0 style', function () { - mapping.loadMappings({ - index: { - mappings: { + test('Simple fields', function () { + mapping.loadMappings({ + index: { properties: { str: { type: 'string', @@ -112,108 +162,130 @@ describe('Autocomplete entities', () => { }, }, }, - }, - }); + }); - expect(mapping.getMappings('index').sort(fc)).toEqual([ - f('number', 'int'), - f('str', 'string'), - ]); - }); + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); - test('Nested fields', function () { - mapping.loadMappings({ - index: { - properties: { - person: { - type: 'object', + test('Simple fields - 1.0 style', function () { + mapping.loadMappings({ + index: { + mappings: { properties: { - name: { - properties: { - first_name: { type: 'string' }, - last_name: { type: 'string' }, - }, + str: { + type: 'string', + }, + number: { + type: 'int', }, - sid: { type: 'string', index: 'not_analyzed' }, }, }, - message: { type: 'string' }, }, - }, - }); + }); - expect(mapping.getMappings('index', []).sort(fc)).toEqual([ - f('message'), - f('person.name.first_name'), - f('person.name.last_name'), - f('person.sid'), - ]); - }); + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); - test('Enabled fields', function () { - mapping.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - type: 'object', - enabled: false, + test('Nested fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + properties: { + first_name: { type: 'string' }, + last_name: { type: 'string' }, + }, + }, + sid: { type: 'string', index: 'not_analyzed' }, }, - sid: { type: 'string', index: 'not_analyzed' }, }, + message: { type: 'string' }, }, - message: { type: 'string' }, }, - }, - }); + }); - expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); - }); + expect(mapping.getMappings('index', []).sort(fc)).toEqual([ + f('message'), + f('person.name.first_name'), + f('person.name.last_name'), + f('person.sid'), + ]); + }); - test('Path tests', function () { - mapping.loadMappings({ - index: { - properties: { - name1: { - type: 'object', - path: 'just_name', - properties: { - first1: { type: 'string' }, - last1: { type: 'string', index_name: 'i_last_1' }, + test('Enabled fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + type: 'object', + enabled: false, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, }, + message: { type: 'string' }, }, - name2: { - type: 'object', - path: 'full', - properties: { - first2: { type: 'string' }, - last2: { type: 'string', index_name: 'i_last_2' }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); + }); + + test('Path tests', function () { + mapping.loadMappings({ + index: { + properties: { + name1: { + type: 'object', + path: 'just_name', + properties: { + first1: { type: 'string' }, + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + name2: { + type: 'object', + path: 'full', + properties: { + first2: { type: 'string' }, + last2: { type: 'string', index_name: 'i_last_2' }, + }, }, }, }, - }, - }); + }); - expect(mapping.getMappings().sort(fc)).toEqual([ - f('first1'), - f('i_last_1'), - f('name2.first2'), - f('name2.i_last_2'), - ]); - }); + expect(mapping.getMappings().sort(fc)).toEqual([ + f('first1'), + f('i_last_1'), + f('name2.first2'), + f('name2.i_last_2'), + ]); + }); - test('Use index_name tests', function () { - mapping.loadMappings({ - index: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, + test('Use index_name tests', function () { + mapping.loadMappings({ + index: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, }, - }, - }); + }); - expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]); + expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]); + }); }); }); diff --git a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts index 71e72dac0a280..3c383ca124167 100644 --- a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts +++ b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts @@ -7,9 +7,15 @@ */ import _ from 'lodash'; +import { BehaviorSubject } from 'rxjs'; import type { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { type Settings } from '../../services'; +import { API_BASE_PATH } from '../../../common/constants'; +import type { ResultTerm, AutoCompleteContext } from '../autocomplete/types'; import { expandAliases } from './expand_aliases'; import type { Field, FieldMapping } from './types'; +import { type AutoCompleteEntitiesApiResponse } from './types'; function getFieldNamesFromProperties(properties: Record = {}) { const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { @@ -70,22 +76,127 @@ function getFieldNamesFromFieldMapping( export interface BaseMapping { perIndexTypes: Record; - getMappings(indices: string | string[], types?: string | string[]): Field[]; + /** + * Fetches mappings definition + */ + fetchMappings(index: string): Promise; + + /** + * Retrieves mappings definition from cache, fetches if necessary. + */ + getMappings( + indices: string | string[], + types?: string | string[], + autoCompleteContext?: AutoCompleteContext + ): Field[]; + + /** + * Stores mappings definition + * @param mappings + */ loadMappings(mappings: IndicesGetMappingResponse): void; clearMappings(): void; } export class Mapping implements BaseMapping { + private http!: HttpSetup; + + private settings!: Settings; + + /** + * Map of the mappings of actual ES indices. + */ public perIndexTypes: Record = {}; - getMappings = (indices: string | string[], types?: string | string[]) => { + private readonly _isLoading$ = new BehaviorSubject(false); + + /** + * Indicates if mapping fetching is in progress. + */ + public readonly isLoading$ = this._isLoading$.asObservable(); + + /** + * Map of the currently loading mappings for index patterns specified by a user. + * @private + */ + private loadingState: Record = {}; + + public setup(http: HttpSetup, settings: Settings) { + this.http = http; + this.settings = settings; + } + + /** + * Fetches mappings of the requested indices. + * @param index + */ + async fetchMappings(index: string): Promise { + const response = await this.http.get( + `${API_BASE_PATH}/autocomplete_entities`, + { + query: { fields: true, fieldsIndices: index }, + } + ); + + return response.mappings; + } + + getMappings = ( + indices: string | string[], + types?: string | string[], + autoCompleteContext?: AutoCompleteContext + ) => { // get fields for indices and types. Both can be a list, a string or null (meaning all). let ret: Field[] = []; + + if (!this.settings.getAutocomplete().fields) return ret; + indices = expandAliases(indices); if (typeof indices === 'string') { const typeDict = this.perIndexTypes[indices] as Record; - if (!typeDict) { + + if (!typeDict || Object.keys(typeDict).length === 0) { + if (!autoCompleteContext) return ret; + + // Mappings fetching for the index is already in progress + if (this.loadingState[indices]) return ret; + + this.loadingState[indices] = true; + + if (!autoCompleteContext.asyncResultsState) { + autoCompleteContext.asyncResultsState = {} as AutoCompleteContext['asyncResultsState']; + } + + autoCompleteContext.asyncResultsState!.isLoading = true; + + autoCompleteContext.asyncResultsState!.results = new Promise( + (resolve, reject) => { + this._isLoading$.next(true); + + this.fetchMappings(indices as string) + .then((mapping) => { + this._isLoading$.next(false); + + autoCompleteContext.asyncResultsState!.isLoading = false; + autoCompleteContext.asyncResultsState!.lastFetched = Date.now(); + + // cache mappings + this.loadMappings(mapping); + + const mappings = this.getMappings(indices, types, autoCompleteContext); + delete this.loadingState[indices as string]; + resolve(mappings); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + this._isLoading$.next(false); + delete this.loadingState[indices as string]; + }); + } + ); + return []; } @@ -108,7 +219,7 @@ export class Mapping implements BaseMapping { // multi index mode. Object.keys(this.perIndexTypes).forEach((index) => { if (!indices || indices.length === 0 || indices.includes(index)) { - ret.push(this.getMappings(index, types) as unknown as Field); + ret.push(this.getMappings(index, types, autoCompleteContext) as unknown as Field); } }); @@ -121,8 +232,6 @@ export class Mapping implements BaseMapping { }; loadMappings = (mappings: IndicesGetMappingResponse) => { - this.perIndexTypes = {}; - Object.entries(mappings).forEach(([index, indexMapping]) => { const normalizedIndexMappings: Record = {}; let transformedMapping: Record = indexMapping; diff --git a/src/plugins/console/public/services/autocomplete.ts b/src/plugins/console/public/services/autocomplete.ts index 3e1a38a514607..57324049bab94 100644 --- a/src/plugins/console/public/services/autocomplete.ts +++ b/src/plugins/console/public/services/autocomplete.ts @@ -53,7 +53,11 @@ export class AutocompleteInfo { const collaborator = this.mapping; return () => this.alias.getIndices(includeAliases, collaborator); case ENTITIES.FIELDS: - return this.mapping.getMappings(context.indices, context.types); + return this.mapping.getMappings( + context.indices, + context.types, + Object.getPrototypeOf(context) + ); case ENTITIES.INDEX_TEMPLATES: return () => this.indexTemplate.getTemplates(); case ENTITIES.COMPONENT_TEMPLATES: @@ -93,7 +97,6 @@ export class AutocompleteInfo { } private load(data: AutoCompleteEntitiesApiResponse) { - this.mapping.loadMappings(data.mappings); const collaborator = this.mapping; this.alias.loadAliases(data.aliases, collaborator); this.indexTemplate.loadTemplates(data.indexTemplates); diff --git a/src/plugins/console/public/services/settings.ts b/src/plugins/console/public/services/settings.ts index e4731dd3f3a31..4056d20063a3e 100644 --- a/src/plugins/console/public/services/settings.ts +++ b/src/plugins/console/public/services/settings.ts @@ -14,7 +14,12 @@ export const DEFAULT_SETTINGS = Object.freeze({ pollInterval: 60000, tripleQuotes: true, wrapMode: true, - autocomplete: Object.freeze({ fields: true, indices: true, templates: true, dataStreams: true }), + autocomplete: Object.freeze({ + fields: true, + indices: true, + templates: true, + dataStreams: true, + }), isHistoryEnabled: true, isKeyboardShortcutsEnabled: true, }); diff --git a/src/plugins/console/public/styles/_app.scss b/src/plugins/console/public/styles/_app.scss index 2490bb29f0fb7..9cbe643722047 100644 --- a/src/plugins/console/public/styles/_app.scss +++ b/src/plugins/console/public/styles/_app.scss @@ -30,7 +30,7 @@ } } -.conApp__output { +.conApp__output, .conApp_example { display: flex; flex: 1 1 1px; diff --git a/src/plugins/console/public/styles/components/_help.scss b/src/plugins/console/public/styles/components/_help.scss deleted file mode 100644 index 594bcfd1db311..0000000000000 --- a/src/plugins/console/public/styles/components/_help.scss +++ /dev/null @@ -1,5 +0,0 @@ -.conHelp__example { - display: block; - height: $euiSize * 8; // 8 lines of code - margin: $euiSize 0; -} diff --git a/src/plugins/console/public/styles/components/_index.scss b/src/plugins/console/public/styles/components/_index.scss index 9dfef202d1254..0af86f54f723f 100644 --- a/src/plugins/console/public/styles/components/_index.scss +++ b/src/plugins/console/public/styles/components/_index.scss @@ -1,2 +1 @@ -@import 'help'; @import 'history'; diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index 1c9d6352914a2..34f44578a0bce 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -7,6 +7,7 @@ */ import type { Editor } from 'brace'; +import { ResultTerm } from '../lib/autocomplete/types'; import { TokensProvider } from './tokens_provider'; import { Token } from './token'; @@ -23,7 +24,11 @@ export type EditorEvent = export type AutoCompleterFunction = ( pos: Position, prefix: string, - callback: (...args: unknown[]) => void + callback: (e: Error | null, result: ResultTerm[] | null) => void, + annotationControls: { + setAnnotation: (text: string) => void; + removeAnnotation: () => void; + } ) => void; export interface Position { diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts index 8bd5f8ee50b48..2d19de0a56e74 100644 --- a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts @@ -6,26 +6,25 @@ * Side Public License, v 1. */ -import { parse } from 'query-string'; import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import type { RouteDependencies } from '../../..'; -interface SettingsToRetrieve { - indices: boolean; - fields: boolean; - templates: boolean; - dataStreams: boolean; -} +import { autoCompleteEntitiesValidationConfig, type SettingsToRetrieve } from './validation_config'; const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB // Limit the response size to 10MB, because the response can be very large and sending it to the client // can cause the browser to hang. const getMappings = async (settings: SettingsToRetrieve, esClient: IScopedClusterClient) => { - if (settings.fields) { - const mappings = await esClient.asInternalUser.indices.getMapping(undefined, { - maxResponseSize: MAX_RESPONSE_SIZE, - maxCompressedResponseSize: MAX_RESPONSE_SIZE, - }); + if (settings.fields && settings.fieldsIndices) { + const mappings = await esClient.asInternalUser.indices.getMapping( + { + index: settings.fieldsIndices, + }, + { + maxResponseSize: MAX_RESPONSE_SIZE, + maxCompressedResponseSize: MAX_RESPONSE_SIZE, + } + ); return mappings; } // If the user doesn't want autocomplete suggestions, then clear any that exist. @@ -87,20 +86,11 @@ export const registerAutocompleteEntitiesRoute = (deps: RouteDependencies) => { options: { tags: ['access:console'], }, - validate: false, + validate: autoCompleteEntitiesValidationConfig, }, async (context, request, response) => { const esClient = (await context.core).elasticsearch.client; - const settings = parse(request.url.search, { - parseBooleans: true, - }) as unknown as SettingsToRetrieve; - - // If no settings are specified, then return 400. - if (Object.keys(settings).length === 0) { - return response.badRequest({ - body: 'Request must contain at least one of the following parameters: indices, fields, templates, dataStreams', - }); - } + const settings = request.query; // Wait for all requests to complete, in case one of them fails return the successfull ones const results = await Promise.allSettled([ diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts new file mode 100644 index 0000000000000..48bab100d0b61 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/validation_config.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const autoCompleteEntitiesValidationConfig = { + query: schema.object( + { + indices: schema.maybe(schema.boolean()), + fields: schema.maybe(schema.boolean()), + templates: schema.maybe(schema.boolean()), + dataStreams: schema.maybe(schema.boolean()), + /** + * Comma separated list of indices for mappings retrieval. + */ + fieldsIndices: schema.maybe(schema.string()), + }, + { + validate: (payload) => { + if (Object.keys(payload).length === 0) { + return 'The request must contain at least one of the following parameters: indices, fields, templates, dataStreams.'; + } + }, + } + ), +}; + +export type SettingsToRetrieve = TypeOf; diff --git a/src/plugins/console/tsconfig.json b/src/plugins/console/tsconfig.json index 0dcc23b1c060c..43b94e47eedd4 100644 --- a/src/plugins/console/tsconfig.json +++ b/src/plugins/console/tsconfig.json @@ -30,6 +30,7 @@ "@kbn/core-http-router-server-internal", "@kbn/web-worker-stub", "@kbn/core-elasticsearch-server", + "@kbn/core-http-browser-mocks", ], "exclude": [ "target/**/*", diff --git a/src/plugins/data_views/common/fields/fields.mocks.ts b/src/plugins/data_views/common/fields/fields.mocks.ts index 05a51d7ccf120..bee37c3eb9761 100644 --- a/src/plugins/data_views/common/fields/fields.mocks.ts +++ b/src/plugins/data_views/common/fields/fields.mocks.ts @@ -306,6 +306,29 @@ export const fields: FieldSpec[] = [ readFromDocValues: false, subType: { nested: { path: 'nestedField.nestedChild' } }, }, + { + name: 'mapping issues', + type: 'conflict', + esTypes: ['text', 'unmapped'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + conflictDescriptions: { + text: [ + '.siem-signals-default-000001', + '.siem-signals-default-000002', + '.siem-signals-default-000011', + '.siem-signals-default-000012', + ], + unmapped: [ + '.siem-signals-default-000004', + '.siem-signals-default-000005', + '.siem-signals-default-000240', + ], + }, + }, ]; export const getField = (name: string) => fields.find((field) => field.name === name) as FieldSpec; diff --git a/src/plugins/home/public/application/components/move_data/move_data.test.tsx b/src/plugins/home/public/application/components/move_data/move_data.test.tsx index 9c92afbf01ac3..7f8e3a1bfcf7a 100644 --- a/src/plugins/home/public/application/components/move_data/move_data.test.tsx +++ b/src/plugins/home/public/application/components/move_data/move_data.test.tsx @@ -17,6 +17,6 @@ describe('MoveData', () => { const component = shallowWithIntl(); const $button = component.find('EuiButton'); - expect($button.props().href).toBe('/app/management/data/migrate_data'); + expect($button.props().href).toBe('https://ela.st/cloud-migration'); }); }); diff --git a/src/plugins/home/public/application/components/move_data/move_data.tsx b/src/plugins/home/public/application/components/move_data/move_data.tsx index ce97e71a6ecd8..c3e0181aa4f64 100644 --- a/src/plugins/home/public/application/components/move_data/move_data.tsx +++ b/src/plugins/home/public/application/components/move_data/move_data.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FC, MouseEvent } from 'react'; +import React, { FC } from 'react'; import { EuiButton, EuiFlexGroup, @@ -19,18 +19,17 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { createAppNavigationHandler } from '../app_navigation_handler'; interface Props { addBasePath: (path: string) => string; } export const MoveData: FC = ({ addBasePath }) => { - const migrateDataUrl = '/app/management/data/migrate_data'; + const migrateDataUrl = 'https://ela.st/cloud-migration'; const buttonLabel = ( ); @@ -50,7 +49,7 @@ export const MoveData: FC = ({ addBasePath }) => {

@@ -58,18 +57,15 @@ export const MoveData: FC = ({ addBasePath }) => { - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { - createAppNavigationHandler(migrateDataUrl)(event); - }} + href={migrateDataUrl} + target="_blank" > {buttonLabel} diff --git a/test/api_integration/apis/console/autocomplete_entities.ts b/test/api_integration/apis/console/autocomplete_entities.ts index 6bd899c979a2b..6e13b2fb2856b 100644 --- a/test/api_integration/apis/console/autocomplete_entities.ts +++ b/test/api_integration/apis/console/autocomplete_entities.ts @@ -128,7 +128,7 @@ export default ({ getService }: FtrProviderContext) => { return await supertest.get('/api/console/autocomplete_entities').query(query); }; - describe('/api/console/autocomplete_entities', () => { + describe('/api/console/autocomplete_entities', function () { const indexName = 'test-index-1'; const aliasName = 'test-alias-1'; const indexTemplateName = 'test-index-template-1'; @@ -238,9 +238,17 @@ export default ({ getService }: FtrProviderContext) => { expect(body.mappings).to.eql({}); }); - it('should return mappings with fields setting is set to true', async () => { + it('should not return mappings with fields setting is set to true without the list of indices is provided', async () => { const response = await sendRequest({ fields: true }); + const { body, status } = response; + expect(status).to.be(200); + expect(Object.keys(body.mappings)).to.not.contain(indexName); + }); + + it('should return mappings with fields setting is set to true and the list of indices is provided', async () => { + const response = await sendRequest({ fields: true, fieldsIndices: indexName }); + const { body, status } = response; expect(status).to.be(200); expect(Object.keys(body.mappings)).to.contain(indexName); diff --git a/x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_items.ts b/x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_item_sets.ts similarity index 92% rename from x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_items.ts rename to x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_item_sets.ts index 268516f95542d..5f3d8ce759e19 100644 --- a/x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_items.ts +++ b/x-pack/plugins/aiops/common/__mocks__/artificial_logs/filtered_frequent_item_sets.ts @@ -7,7 +7,7 @@ import type { ItemsetResult } from '../../types'; -export const filteredFrequentItems: ItemsetResult[] = [ +export const filteredFrequentItemSets: ItemsetResult[] = [ { set: { response_code: '500', url: 'home.php' }, size: 2, diff --git a/x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_items.ts b/x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_item_sets.ts similarity index 96% rename from x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_items.ts rename to x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_item_sets.ts index fe61a60a1afbe..783ea7d674d3d 100644 --- a/x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_items.ts +++ b/x-pack/plugins/aiops/common/__mocks__/artificial_logs/frequent_item_sets.ts @@ -7,7 +7,7 @@ import type { ItemsetResult } from '../../types'; -export const frequentItems: ItemsetResult[] = [ +export const frequentItemSets: ItemsetResult[] = [ { set: { response_code: '500', url: 'home.php' }, size: 2, diff --git a/x-pack/plugins/aiops/public/application/utils/search_utils.ts b/x-pack/plugins/aiops/public/application/utils/search_utils.ts index 3bd0ea7a96523..f8f570d475fc0 100644 --- a/x-pack/plugins/aiops/public/application/utils/search_utils.ts +++ b/x-pack/plugins/aiops/public/application/utils/search_utils.ts @@ -11,8 +11,8 @@ import { cloneDeep } from 'lodash'; import { IUiSettingsClient } from '@kbn/core/public'; import { getEsQueryConfig, SearchSource } from '@kbn/data-plugin/common'; -import { SavedSearch } from '@kbn/discover-plugin/public'; -import { FilterManager } from '@kbn/data-plugin/public'; +import type { SavedSearch } from '@kbn/discover-plugin/public'; +import { FilterManager, isQuery, mapAndFlattenFilters } from '@kbn/data-plugin/public'; import { fromKueryExpression, toElasticsearchQuery, @@ -20,6 +20,7 @@ import { buildEsQuery, Query, Filter, + AggregateQuery, } from '@kbn/es-query'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -88,14 +89,15 @@ export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObjec * Should also form a valid query if only the query or filters is provided */ export function createMergedEsQuery( - query?: Query, + query?: Query | AggregateQuery, filters?: Filter[], dataView?: DataView, uiSettings?: IUiSettingsClient ) { let combinedQuery: QueryDslQueryContainer = getDefaultQuery(); - if (query && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + // FIXME: Add support for AggregateQuery type #150091 + if (isQuery(query) && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { const ast = fromKueryExpression(query.query); if (query.query !== '') { combinedQuery = toElasticsearchQuery(ast, dataView); @@ -127,6 +129,14 @@ export function createMergedEsQuery( return combinedQuery; } +function getSavedSearchSource(savedSearch: SavedSearch) { + return savedSearch && + 'searchSource' in savedSearch && + savedSearch?.searchSource instanceof SearchSource + ? savedSearch.searchSource + : undefined; +} + /** * Extract query data from the saved search object * with overrides from the provided query data and/or filters @@ -141,7 +151,7 @@ export function getEsQueryFromSavedSearch({ }: { dataView: DataView; uiSettings: IUiSettingsClient; - savedSearch: SavedSearchSavedObject | SavedSearch | null | undefined; + savedSearch: SavedSearch | null | undefined; query?: Query; filters?: Filter[]; filterManager?: FilterManager; @@ -151,17 +161,13 @@ export function getEsQueryFromSavedSearch({ const userQuery = query; const userFilters = filters; + const savedSearchSource = getSavedSearchSource(savedSearch); + // If saved search has a search source with nested parent // e.g. a search coming from Dashboard saved search embeddable // which already combines both the saved search's original query/filters and the Dashboard's // then no need to process any further - if ( - savedSearch && - 'searchSource' in savedSearch && - savedSearch?.searchSource instanceof SearchSource && - savedSearch.searchSource.getParent() !== undefined && - userQuery - ) { + if (savedSearchSource && savedSearchSource.getParent() !== undefined && userQuery) { // Flattened query from search source may contain a clause that narrows the time range // which might interfere with global time pickers so we need to remove const savedQuery = @@ -181,12 +187,8 @@ export function getEsQueryFromSavedSearch({ }; } - // If saved search is an json object with the original query and filter - // retrieve the parsed query and filter - const savedSearchData = getQueryFromSavedSearchObject(savedSearch); - // If no saved search available, use user's query and filters - if (!savedSearchData && userQuery) { + if (!savedSearch && userQuery) { if (filterManager && userFilters) filterManager.addFilters(userFilters); const combinedQuery = createMergedEsQuery( @@ -205,11 +207,12 @@ export function getEsQueryFromSavedSearch({ // If saved search available, merge saved search with the latest user query or filters // which might differ from extracted saved search data - if (savedSearchData) { + if (savedSearchSource) { const globalFilters = filterManager?.getGlobalFilters(); - const currentQuery = userQuery ?? savedSearchData?.query; - const currentFilters = userFilters ?? savedSearchData?.filter; - + // FIXME: Add support for AggregateQuery type #150091 + const currentQuery = userQuery ?? (savedSearchSource.getField('query') as Query); + const currentFilters = + userFilters ?? mapAndFlattenFilters(savedSearchSource.getField('filter') as Filter[]); if (filterManager) filterManager.setFilters(currentFilters); if (globalFilters) filterManager?.addFilters(globalFilters); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx index fb9dbc5156ed4..d2d275f0b2251 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detetion_root.tsx @@ -11,7 +11,7 @@ import { pick } from 'lodash'; import { EuiSpacer } from '@elastic/eui'; import { DataView } from '@kbn/data-views-plugin/common'; -import { SavedSearch } from '@kbn/saved-search-plugin/public'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { StorageContextProvider } from '@kbn/ml-local-storage'; import { UrlStateProvider } from '@kbn/ml-url-state'; import { Storage } from '@kbn/kibana-utils-plugin/public'; @@ -20,7 +20,6 @@ import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import { DataSourceContext } from '../../hooks/use_data_source'; -import { SavedSearchSavedObject } from '../../application/utils/search_utils'; import { AiopsAppContext, AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AIOPS_STORAGE_KEYS } from '../../types/storage'; @@ -33,7 +32,7 @@ const localStorage = new Storage(window.localStorage); export interface ChangePointDetectionAppStateProps { dataView: DataView; - savedSearch: SavedSearch | SavedSearchSavedObject | null; + savedSearch: SavedSearch | null; appDependencies: AiopsAppDependencies; } diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx index ae67c55d87f05..f43b60d7daffa 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_app_state.tsx @@ -21,11 +21,7 @@ import { DatePickerContextProvider } from '@kbn/ml-date-picker'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; -import { - SEARCH_QUERY_LANGUAGE, - SearchQueryLanguage, - SavedSearchSavedObject, -} from '../../application/utils/search_utils'; +import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../../application/utils/search_utils'; import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AiopsAppContext } from '../../hooks/use_aiops_app_context'; import { DataSourceContext } from '../../hooks/use_data_source'; @@ -41,7 +37,7 @@ export interface ExplainLogRateSpikesAppStateProps { /** The data view to analyze. */ dataView: DataView; /** The saved search to analyze. */ - savedSearch: SavedSearch | SavedSearchSavedObject | null; + savedSearch: SavedSearch | null; /** App dependencies */ appDependencies: AiopsAppDependencies; } diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx index 8f4504070cc08..6c277456d2ec3 100644 --- a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes/explain_log_rate_spikes_page.tsx @@ -67,11 +67,11 @@ export const ExplainLogRateSpikesPage: FC = () => { ); const [globalState, setGlobalState] = useUrlState('_g'); - const [currentSavedSearch, setCurrentSavedSearch] = useState(savedSearch); + const [selectedSavedSearch, setSelectedSavedSearch] = useState(savedSearch); useEffect(() => { if (savedSearch) { - setCurrentSavedSearch(savedSearch); + setSelectedSavedSearch(savedSearch); } }, [savedSearch]); @@ -84,8 +84,8 @@ export const ExplainLogRateSpikesPage: FC = () => { }) => { // When the user loads a saved search and then clears or modifies the query // we should remove the saved search and replace it with the index pattern id - if (currentSavedSearch !== null) { - setCurrentSavedSearch(null); + if (selectedSavedSearch !== null) { + setSelectedSavedSearch(null); } setAiopsListState({ @@ -96,7 +96,7 @@ export const ExplainLogRateSpikesPage: FC = () => { filters: searchParams.filters, }); }, - [currentSavedSearch, aiopsListState, setAiopsListState] + [selectedSavedSearch, aiopsListState, setAiopsListState] ); const { @@ -108,7 +108,7 @@ export const ExplainLogRateSpikesPage: FC = () => { searchString, searchQuery, } = useData( - { currentDataView: dataView, currentSavedSearch }, + { selectedDataView: dataView, selectedSavedSearch }, aiopsListState, setGlobalState, currentSelectedChangePoint, diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx index 235aa1a518403..b554013393926 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_app_state.tsx @@ -17,7 +17,6 @@ import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import { DataSourceContext } from '../../hooks/use_data_source'; -import { SavedSearchSavedObject } from '../../application/utils/search_utils'; import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context'; import { AIOPS_STORAGE_KEYS } from '../../types/storage'; import { AiopsAppContext } from '../../hooks/use_aiops_app_context'; @@ -28,7 +27,7 @@ const localStorage = new Storage(window.localStorage); export interface LogCategorizationAppStateProps { dataView: DataView; - savedSearch: SavedSearch | SavedSearchSavedObject | null; + savedSearch: SavedSearch | null; appDependencies: AiopsAppDependencies; } diff --git a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx index ab66a75754e63..feb6ef9c9b31c 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx +++ b/x-pack/plugins/aiops/public/components/log_categorization/log_categorization_page.tsx @@ -52,13 +52,19 @@ export const LogCategorizationPage: FC = () => { const [selectedField, setSelectedField] = useState(); const [selectedCategory, setSelectedCategory] = useState(null); const [categories, setCategories] = useState(null); - const [currentSavedSearch, setCurrentSavedSearch] = useState(savedSearch); + const [selectedSavedSearch, setSelectedDataView] = useState(savedSearch); const [loading, setLoading] = useState(false); const [totalCount, setTotalCount] = useState(0); const [eventRate, setEventRate] = useState([]); const [pinnedCategory, setPinnedCategory] = useState(null); const [sparkLines, setSparkLines] = useState({}); + useEffect(() => { + if (savedSearch) { + setSelectedDataView(savedSearch); + } + }, [savedSearch]); + useEffect( function cancelRequestOnLeave() { return () => { @@ -77,8 +83,8 @@ export const LogCategorizationPage: FC = () => { }) => { // When the user loads saved search and then clear or modify the query // we should remove the saved search and replace it with the index pattern id - if (currentSavedSearch !== null) { - setCurrentSavedSearch(null); + if (selectedSavedSearch !== null) { + setSelectedDataView(null); } setAiopsListState({ @@ -89,7 +95,7 @@ export const LogCategorizationPage: FC = () => { filters: searchParams.filters, }); }, - [currentSavedSearch, aiopsListState, setAiopsListState] + [selectedSavedSearch, aiopsListState, setAiopsListState] ); const { @@ -102,7 +108,7 @@ export const LogCategorizationPage: FC = () => { searchQuery, intervalMs, } = useData( - { currentDataView: dataView, currentSavedSearch }, + { selectedDataView: dataView, selectedSavedSearch }, aiopsListState, setGlobalState, undefined, diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 3cace69ed4e98..ff04b27b44e3c 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -16,10 +16,7 @@ import { mlTimefilterRefresh$, useTimefilter } from '@kbn/ml-date-picker'; import type { DocumentStatsSearchStrategyParams } from '../get_document_stats'; import type { AiOpsIndexBasedAppState } from '../components/explain_log_rate_spikes/explain_log_rate_spikes_app_state'; -import { - getEsQueryFromSavedSearch, - SavedSearchSavedObject, -} from '../application/utils/search_utils'; +import { getEsQueryFromSavedSearch } from '../application/utils/search_utils'; import type { GroupTableItem } from '../components/spike_analysis_table/types'; import { useTimeBuckets } from './use_time_buckets'; @@ -31,9 +28,9 @@ const DEFAULT_BAR_TARGET = 75; export const useData = ( { - currentDataView, - currentSavedSearch, - }: { currentDataView: DataView; currentSavedSearch: SavedSearch | SavedSearchSavedObject | null }, + selectedDataView, + selectedSavedSearch, + }: { selectedDataView: DataView; selectedSavedSearch: SavedSearch | null }, aiopsListState: AiOpsIndexBasedAppState, onUpdate: (params: Dictionary) => void, selectedChangePoint?: ChangePoint, @@ -55,9 +52,9 @@ export const useData = ( /** Prepare required params to pass to search strategy **/ const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { const searchData = getEsQueryFromSavedSearch({ - dataView: currentDataView, + dataView: selectedDataView, uiSettings, - savedSearch: currentSavedSearch, + savedSearch: selectedSavedSearch, filterManager, }); @@ -82,8 +79,8 @@ export const useData = ( } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - currentSavedSearch?.id, - currentDataView.id, + selectedSavedSearch?.id, + selectedDataView.id, aiopsListState.searchString, aiopsListState.searchQueryLanguage, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -96,7 +93,7 @@ export const useData = ( const _timeBuckets = useTimeBuckets(); const timefilter = useTimefilter({ - timeRangeSelector: currentDataView?.timeFieldName !== undefined, + timeRangeSelector: selectedDataView?.timeFieldName !== undefined, autoRefreshSelector: true, }); @@ -138,10 +135,10 @@ export const useData = ( earliest: timefilterActiveBounds.min?.valueOf(), latest: timefilterActiveBounds.max?.valueOf(), intervalMs: _timeBuckets.getInterval()?.asMilliseconds(), - index: currentDataView.getIndexPattern(), + index: selectedDataView.getIndexPattern(), searchQuery, - timeFieldName: currentDataView.timeFieldName, - runtimeFieldMap: currentDataView.getRuntimeMappings(), + timeFieldName: selectedDataView.timeFieldName, + runtimeFieldMap: selectedDataView.getRuntimeMappings(), }); setLastRefresh(Date.now()); } diff --git a/x-pack/plugins/aiops/public/hooks/use_data_source.ts b/x-pack/plugins/aiops/public/hooks/use_data_source.ts index e67aad07a338c..64cf4fe8ec521 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data_source.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data_source.ts @@ -7,12 +7,11 @@ import { createContext, useContext } from 'react'; import { DataView } from '@kbn/data-views-plugin/common'; -import { SavedSearch } from '@kbn/saved-search-plugin/public'; -import { SavedSearchSavedObject } from '../application/utils/search_utils'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; export const DataSourceContext = createContext<{ dataView: DataView | never; - savedSearch: SavedSearch | SavedSearchSavedObject | null; + savedSearch: SavedSearch | null; }>({ get dataView(): never { throw new Error('DataSourceContext is not implemented'); diff --git a/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts index dfbc313632efc..f8fac77a63cd8 100644 --- a/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts +++ b/x-pack/plugins/aiops/server/routes/explain_log_rate_spikes.ts @@ -44,7 +44,7 @@ import type { AiopsLicense } from '../types'; import { duplicateIdentifier } from './queries/duplicate_identifier'; import { fetchChangePointPValues } from './queries/fetch_change_point_p_values'; import { fetchIndexInfo } from './queries/fetch_index_info'; -import { dropDuplicates, fetchFrequentItems } from './queries/fetch_frequent_items'; +import { dropDuplicates, fetchFrequentItemSets } from './queries/fetch_frequent_item_sets'; import { getHistogramQuery } from './queries/get_histogram_query'; import { getGroupFilter } from './queries/get_group_filter'; import { getChangePointGroups } from './queries/get_change_point_groups'; @@ -423,11 +423,11 @@ export const defineExplainLogRateSpikesRoute = ( }) ); - // Deduplicated change points we pass to the `frequent_items` aggregation. + // Deduplicated change points we pass to the `frequent_item_sets` aggregation. const deduplicatedChangePoints = dropDuplicates(changePoints, duplicateIdentifier); try { - const { fields, df } = await fetchFrequentItems( + const { fields, df } = await fetchFrequentItemSets( client, request.body.index, JSON.parse(request.body.searchQuery) as estypes.QueryDslQueryContainer, @@ -442,7 +442,7 @@ export const defineExplainLogRateSpikesRoute = ( ); if (shouldStop) { - logDebugMessage('shouldStop after fetching frequent_items.'); + logDebugMessage('shouldStop after fetching frequent_item_sets.'); end(); return; } diff --git a/x-pack/plugins/aiops/server/routes/queries/duplicate_identifier.ts b/x-pack/plugins/aiops/server/routes/queries/duplicate_identifier.ts index d996c060f7fe9..b8e24946672dc 100644 --- a/x-pack/plugins/aiops/server/routes/queries/duplicate_identifier.ts +++ b/x-pack/plugins/aiops/server/routes/queries/duplicate_identifier.ts @@ -7,7 +7,7 @@ import type { ChangePoint } from '@kbn/ml-agg-utils'; -// To optimize the `frequent_items` query, we identify duplicate change points by count attributes. +// To optimize the `frequent_item_sets` query, we identify duplicate change points by count attributes. // Note this is a compromise and not 100% accurate because there could be change points that // have the exact same counts but still don't co-occur. export const duplicateIdentifier: Array = [ diff --git a/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts b/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_item_sets.ts similarity index 84% rename from x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts rename to x-pack/plugins/aiops/server/routes/queries/fetch_frequent_item_sets.ts index 76aeeb1eabcdb..2f28a55ceeb2d 100644 --- a/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_items.ts +++ b/x-pack/plugins/aiops/server/routes/queries/fetch_frequent_item_sets.ts @@ -16,16 +16,16 @@ import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import type { ChangePointDuplicateGroup, ItemsetResult } from '../../../common/types'; -const FREQUENT_ITEMS_FIELDS_LIMIT = 15; +const FREQUENT_ITEM_SETS_FIELDS_LIMIT = 15; -interface FrequentItemsAggregation extends estypes.AggregationsSamplerAggregation { +interface FrequentItemSetsAggregation extends estypes.AggregationsSamplerAggregation { fi: { buckets: Array<{ key: Record; doc_count: number; support: number }>; }; } interface RandomSamplerAggregation { - sample: FrequentItemsAggregation; + sample: FrequentItemSetsAggregation; } function isRandomSamplerAggregation(arg: unknown): arg is RandomSamplerAggregation { @@ -56,7 +56,7 @@ export function groupDuplicates(cps: ChangePoint[], uniqueFields: Array((p, c) => { - if (p.length < FREQUENT_ITEMS_FIELDS_LIMIT && !p.some((d) => d === c.fieldName)) { + if (p.length < FREQUENT_ITEM_SETS_FIELDS_LIMIT && !p.some((d) => d === c.fieldName)) { p.push(c.fieldName); } return p; @@ -107,10 +107,10 @@ export async function fetchFrequentItems( field, })); - const frequentItemsAgg: Record = { + const frequentItemSetsAgg: Record = { fi: { - // @ts-expect-error `frequent_items` is not yet part of `AggregationsAggregationContainer` - frequent_items: { + // @ts-expect-error `frequent_item_sets` is not yet part of `AggregationsAggregationContainer` + frequent_item_sets: { minimum_set_size: 2, size: 200, minimum_support: 0.1, @@ -127,20 +127,20 @@ export async function fetchFrequentItems( probability: sampleProbability, seed: RANDOM_SAMPLER_SEED, }, - aggs: frequentItemsAgg, + aggs: frequentItemSetsAgg, }, }; const esBody = { query, - aggs: sampleProbability < 1 ? randomSamplerAgg : frequentItemsAgg, + aggs: sampleProbability < 1 ? randomSamplerAgg : frequentItemSetsAgg, size: 0, track_total_hits: true, }; const body = await client.search< unknown, - { sample: FrequentItemsAggregation } | FrequentItemsAggregation + { sample: FrequentItemSetsAggregation } | FrequentItemSetsAggregation >( { index, @@ -151,8 +151,8 @@ export async function fetchFrequentItems( ); if (body.aggregations === undefined) { - logger.error(`Failed to fetch frequent_items, got: \n${JSON.stringify(body, null, 2)}`); - emitError(`Failed to fetch frequent_items.`); + logger.error(`Failed to fetch frequent_item_sets, got: \n${JSON.stringify(body, null, 2)}`); + emitError(`Failed to fetch frequent_item_sets.`); return { fields: [], df: [], @@ -162,17 +162,17 @@ export async function fetchFrequentItems( const totalDocCountFi = (body.hits.total as estypes.SearchTotalHits).value; - const frequentItems = isRandomSamplerAggregation(body.aggregations) + const frequentItemSets = isRandomSamplerAggregation(body.aggregations) ? body.aggregations.sample.fi : body.aggregations.fi; - const shape = frequentItems.buckets.length; + const shape = frequentItemSets.buckets.length; let maximum = shape; if (maximum > 50000) { maximum = 50000; } - const fiss = frequentItems.buckets; + const fiss = frequentItemSets.buckets; fiss.length = maximum; const results: ItemsetResult[] = []; diff --git a/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.test.ts index 2496e9e927f0e..7340d8e834d10 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.test.ts @@ -6,7 +6,7 @@ */ import { fields } from '../../../common/__mocks__/artificial_logs/fields'; -import { frequentItems } from '../../../common/__mocks__/artificial_logs/frequent_items'; +import { frequentItemSets } from '../../../common/__mocks__/artificial_logs/frequent_item_sets'; import { changePoints } from '../../../common/__mocks__/artificial_logs/change_points'; import { finalChangePointGroups } from '../../../common/__mocks__/artificial_logs/final_change_point_groups'; @@ -14,7 +14,7 @@ import { getChangePointGroups } from './get_change_point_groups'; describe('getChangePointGroups', () => { it('gets change point groups', () => { - const changePointGroups = getChangePointGroups(frequentItems, changePoints, fields); + const changePointGroups = getChangePointGroups(frequentItemSets, changePoints, fields); expect(changePointGroups).toEqual(finalChangePointGroups); }); diff --git a/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.ts b/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.ts index fa0722b15c9a4..5b9920c180d45 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_change_point_groups.ts @@ -8,12 +8,12 @@ import type { ChangePoint, ChangePointGroup } from '@kbn/ml-agg-utils'; import { duplicateIdentifier } from './duplicate_identifier'; -import { dropDuplicates, groupDuplicates } from './fetch_frequent_items'; +import { dropDuplicates, groupDuplicates } from './fetch_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getMarkedDuplicates } from './get_marked_duplicates'; import { getSimpleHierarchicalTree } from './get_simple_hierarchical_tree'; import { getSimpleHierarchicalTreeLeaves } from './get_simple_hierarchical_tree_leaves'; -import { getFilteredFrequentItems } from './get_filtered_frequent_items'; +import { getFilteredFrequentItemSets } from './get_filtered_frequent_item_sets'; import { getGroupsWithReaddedDuplicates } from './get_groups_with_readded_duplicates'; import { getMissingChangePoints } from './get_missing_change_points'; import { transformChangePointToGroup } from './transform_change_point_to_group'; @@ -24,22 +24,22 @@ export function getChangePointGroups( changePoints: ChangePoint[], fields: string[] ): ChangePointGroup[] { - // These are the deduplicated change points we pass to the `frequent_items` aggregation. + // These are the deduplicated change points we pass to the `frequent_item_sets` aggregation. const deduplicatedChangePoints = dropDuplicates(changePoints, duplicateIdentifier); // We use the grouped change points to later repopulate - // the `frequent_items` result with the missing duplicates. + // the `frequent_item_sets` result with the missing duplicates. const groupedChangePoints = groupDuplicates(changePoints, duplicateIdentifier).filter( (g) => g.group.length > 1 ); - const filteredDf = getFilteredFrequentItems(itemsets, changePoints); + const filteredDf = getFilteredFrequentItemSets(itemsets, changePoints); - // `frequent_items` returns lot of different small groups of field/value pairs that co-occur. + // `frequent_item_sets` returns lot of different small groups of field/value pairs that co-occur. // The following steps analyse these small groups, identify overlap between these groups, // and then summarize them in larger groups where possible. - // Get a tree structure based on `frequent_items`. + // Get a tree structure based on `frequent_item_sets`. const { root } = getSimpleHierarchicalTree(filteredDf, true, false, fields); // Each leave of the tree will be a summarized group of co-occuring field/value pairs. @@ -48,7 +48,7 @@ export function getChangePointGroups( // To be able to display a more cleaned up results table in the UI, we identify field/value pairs // that occur in multiple groups. This will allow us to highlight field/value pairs that are // unique to a group in a better way. This step will also re-add duplicates we identified in the - // beginning and didn't pass on to the `frequent_items` agg. + // beginning and didn't pass on to the `frequent_item_sets` agg. const fieldValuePairCounts = getFieldValuePairCounts(treeLeaves); const changePointGroupsWithMarkedDuplicates = getMarkedDuplicates( treeLeaves, @@ -59,7 +59,7 @@ export function getChangePointGroups( groupedChangePoints ); - // Some field/value pairs might not be part of the `frequent_items` result set, for example + // Some field/value pairs might not be part of the `frequent_item_sets` result set, for example // because they don't co-occur with other field/value pairs or because of the limits we set on the query. // In this next part we identify those missing pairs and add them as individual groups. const missingChangePoints = getMissingChangePoints(deduplicatedChangePoints, changePointGroups); diff --git a/x-pack/plugins/aiops/server/routes/queries/get_field_value_pair_counts.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_field_value_pair_counts.test.ts index d5b56751fa173..7c104e2ecb586 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_field_value_pair_counts.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_field_value_pair_counts.test.ts @@ -7,7 +7,7 @@ import { changePointGroups } from '../../../common/__mocks__/farequote/change_point_groups'; import { fields } from '../../../common/__mocks__/artificial_logs/fields'; -import { filteredFrequentItems } from '../../../common/__mocks__/artificial_logs/filtered_frequent_items'; +import { filteredFrequentItemSets } from '../../../common/__mocks__/artificial_logs/filtered_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getSimpleHierarchicalTree } from './get_simple_hierarchical_tree'; @@ -30,7 +30,7 @@ describe('getFieldValuePairCounts', () => { it('returns a nested record with field/value pair counts for artificial logs', () => { const simpleHierarchicalTree = getSimpleHierarchicalTree( - filteredFrequentItems, + filteredFrequentItemSets, true, false, fields diff --git a/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.test.ts new file mode 100644 index 0000000000000..ed907770d3484 --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { changePoints } from '../../../common/__mocks__/artificial_logs/change_points'; +import { frequentItemSets } from '../../../common/__mocks__/artificial_logs/frequent_item_sets'; +import { filteredFrequentItemSets } from '../../../common/__mocks__/artificial_logs/filtered_frequent_item_sets'; + +import { getFilteredFrequentItemSets } from './get_filtered_frequent_item_sets'; + +describe('getFilteredFrequentItemSets', () => { + it('filter frequent item set based on provided change points', () => { + expect(getFilteredFrequentItemSets(frequentItemSets, changePoints)).toStrictEqual( + filteredFrequentItemSets + ); + }); +}); diff --git a/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.ts b/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.ts similarity index 91% rename from x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.ts rename to x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.ts index e071621f6d9f7..15ba268fa733c 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_item_sets.ts @@ -11,10 +11,10 @@ import type { ChangePoint } from '@kbn/ml-agg-utils'; import type { ItemsetResult } from '../../../common/types'; -// The way the `frequent_items` aggregation works could return item sets that include +// The way the `frequent_item_sets` aggregation works could return item sets that include // field/value pairs that are not part of the original list of significant change points. // This cleans up groups and removes those unrelated field/value pairs. -export function getFilteredFrequentItems( +export function getFilteredFrequentItemSets( itemsets: ItemsetResult[], changePoints: ChangePoint[] ): ItemsetResult[] { diff --git a/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.test.ts deleted file mode 100644 index 8399c0366dea1..0000000000000 --- a/x-pack/plugins/aiops/server/routes/queries/get_filtered_frequent_items.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { changePoints } from '../../../common/__mocks__/artificial_logs/change_points'; -import { frequentItems } from '../../../common/__mocks__/artificial_logs/frequent_items'; -import { filteredFrequentItems } from '../../../common/__mocks__/artificial_logs/filtered_frequent_items'; - -import { getFilteredFrequentItems } from './get_filtered_frequent_items'; - -describe('getFilteredFrequentItems', () => { - it('filter frequent item set based on provided change points', () => { - expect(getFilteredFrequentItems(frequentItems, changePoints)).toStrictEqual( - filteredFrequentItems - ); - }); -}); diff --git a/x-pack/plugins/aiops/server/routes/queries/get_groups_with_readded_duplicates.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_groups_with_readded_duplicates.test.ts index 50b5719e49fa4..257acff9793b5 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_groups_with_readded_duplicates.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_groups_with_readded_duplicates.test.ts @@ -10,7 +10,7 @@ import { changePoints } from '../../../common/__mocks__/artificial_logs/change_p import { duplicateIdentifier } from './duplicate_identifier'; import { getGroupsWithReaddedDuplicates } from './get_groups_with_readded_duplicates'; -import { groupDuplicates } from './fetch_frequent_items'; +import { groupDuplicates } from './fetch_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getMarkedDuplicates } from './get_marked_duplicates'; diff --git a/x-pack/plugins/aiops/server/routes/queries/get_marked_duplicates.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_marked_duplicates.test.ts index e44a26d70494c..f84f08e235b6a 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_marked_duplicates.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_marked_duplicates.test.ts @@ -7,7 +7,7 @@ import { changePointGroups } from '../../../common/__mocks__/farequote/change_point_groups'; import { fields } from '../../../common/__mocks__/artificial_logs/fields'; -import { filteredFrequentItems } from '../../../common/__mocks__/artificial_logs/filtered_frequent_items'; +import { filteredFrequentItemSets } from '../../../common/__mocks__/artificial_logs/filtered_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getMarkedDuplicates } from './get_marked_duplicates'; @@ -59,7 +59,7 @@ describe('markDuplicates', () => { it('marks duplicates based on change point groups for artificial logs', () => { const simpleHierarchicalTree = getSimpleHierarchicalTree( - filteredFrequentItems, + filteredFrequentItemSets, true, false, fields diff --git a/x-pack/plugins/aiops/server/routes/queries/get_missing_change_points.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_missing_change_points.test.ts index 477321b74e007..757097c84e0c8 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_missing_change_points.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_missing_change_points.test.ts @@ -10,7 +10,7 @@ import { changePoints } from '../../../common/__mocks__/artificial_logs/change_p import { duplicateIdentifier } from './duplicate_identifier'; import { getGroupsWithReaddedDuplicates } from './get_groups_with_readded_duplicates'; -import { dropDuplicates, groupDuplicates } from './fetch_frequent_items'; +import { dropDuplicates, groupDuplicates } from './fetch_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getMarkedDuplicates } from './get_marked_duplicates'; import { getMissingChangePoints } from './get_missing_change_points'; diff --git a/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree.test.ts index 36cc113ad7be0..e7f40a795bc16 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree.test.ts @@ -6,7 +6,7 @@ */ import { fields } from '../../../common/__mocks__/artificial_logs/fields'; -import { filteredFrequentItems } from '../../../common/__mocks__/artificial_logs/filtered_frequent_items'; +import { filteredFrequentItemSets } from '../../../common/__mocks__/artificial_logs/filtered_frequent_item_sets'; import { getSimpleHierarchicalTree } from './get_simple_hierarchical_tree'; @@ -16,7 +16,7 @@ describe('getSimpleHierarchicalTree', () => { // and make it comparable against a static representation. expect( JSON.parse( - JSON.stringify(getSimpleHierarchicalTree(filteredFrequentItems, true, false, fields)) + JSON.stringify(getSimpleHierarchicalTree(filteredFrequentItemSets, true, false, fields)) ) ).toEqual({ root: { diff --git a/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree_leaves.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree_leaves.test.ts index 9567d38f3d402..89d345bf1d82d 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree_leaves.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_simple_hierarchical_tree_leaves.test.ts @@ -6,7 +6,7 @@ */ import { fields } from '../../../common/__mocks__/artificial_logs/fields'; -import { filteredFrequentItems } from '../../../common/__mocks__/artificial_logs/filtered_frequent_items'; +import { filteredFrequentItemSets } from '../../../common/__mocks__/artificial_logs/filtered_frequent_item_sets'; import { getSimpleHierarchicalTree } from './get_simple_hierarchical_tree'; import { getSimpleHierarchicalTreeLeaves } from './get_simple_hierarchical_tree_leaves'; @@ -14,7 +14,7 @@ import { getSimpleHierarchicalTreeLeaves } from './get_simple_hierarchical_tree_ describe('getSimpleHierarchicalTreeLeaves', () => { it('returns the hierarchical tree leaves', () => { const simpleHierarchicalTree = getSimpleHierarchicalTree( - filteredFrequentItems, + filteredFrequentItemSets, true, false, fields diff --git a/x-pack/plugins/aiops/server/routes/queries/get_value_counts.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_value_counts.test.ts index 744179c485caa..89d7880b5250b 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_value_counts.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_value_counts.test.ts @@ -5,12 +5,12 @@ * 2.0. */ -import { frequentItems } from '../../../common/__mocks__/artificial_logs/frequent_items'; +import { frequentItemSets } from '../../../common/__mocks__/artificial_logs/frequent_item_sets'; import { getValueCounts } from './get_value_counts'; describe('getValueCounts', () => { it('get value counts for field response_code', () => { - expect(getValueCounts(frequentItems, 'response_code')).toEqual({ + expect(getValueCounts(frequentItemSets, 'response_code')).toEqual({ '200': 1, '404': 1, '500': 3, @@ -18,11 +18,11 @@ describe('getValueCounts', () => { }); it('get value counts for field url', () => { - expect(getValueCounts(frequentItems, 'url')).toEqual({ 'home.php': 6 }); + expect(getValueCounts(frequentItemSets, 'url')).toEqual({ 'home.php': 6 }); }); it('get value counts for field user', () => { - expect(getValueCounts(frequentItems, 'user')).toEqual({ + expect(getValueCounts(frequentItemSets, 'user')).toEqual({ Mary: 1, Paul: 1, Peter: 3, diff --git a/x-pack/plugins/aiops/server/routes/queries/get_values_descending.test.ts b/x-pack/plugins/aiops/server/routes/queries/get_values_descending.test.ts index cd4935b4fcc8f..e3519995fc916 100644 --- a/x-pack/plugins/aiops/server/routes/queries/get_values_descending.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/get_values_descending.test.ts @@ -5,19 +5,19 @@ * 2.0. */ -import { frequentItems } from '../../../common/__mocks__/artificial_logs/frequent_items'; +import { frequentItemSets } from '../../../common/__mocks__/artificial_logs/frequent_item_sets'; import { getValuesDescending } from './get_values_descending'; describe('getValuesDescending', () => { it('get descending values for field response_code', () => { - expect(getValuesDescending(frequentItems, 'response_code')).toEqual(['500', '200', '404']); + expect(getValuesDescending(frequentItemSets, 'response_code')).toEqual(['500', '200', '404']); }); it('get descending values for field url', () => { - expect(getValuesDescending(frequentItems, 'url')).toEqual(['home.php']); + expect(getValuesDescending(frequentItemSets, 'url')).toEqual(['home.php']); }); it('get descending values for field user', () => { - expect(getValuesDescending(frequentItems, 'user')).toEqual(['Peter', 'Mary', 'Paul']); + expect(getValuesDescending(frequentItemSets, 'user')).toEqual(['Peter', 'Mary', 'Paul']); }); }); diff --git a/x-pack/plugins/aiops/server/routes/queries/transform_change_point_to_group.test.ts b/x-pack/plugins/aiops/server/routes/queries/transform_change_point_to_group.test.ts index 448f3003fc924..c1ed0bcbcb6ab 100644 --- a/x-pack/plugins/aiops/server/routes/queries/transform_change_point_to_group.test.ts +++ b/x-pack/plugins/aiops/server/routes/queries/transform_change_point_to_group.test.ts @@ -10,7 +10,7 @@ import { changePoints } from '../../../common/__mocks__/artificial_logs/change_p import { duplicateIdentifier } from './duplicate_identifier'; import { getGroupsWithReaddedDuplicates } from './get_groups_with_readded_duplicates'; -import { dropDuplicates, groupDuplicates } from './fetch_frequent_items'; +import { dropDuplicates, groupDuplicates } from './fetch_frequent_item_sets'; import { getFieldValuePairCounts } from './get_field_value_pair_counts'; import { getMarkedDuplicates } from './get_marked_duplicates'; import { getMissingChangePoints } from './get_missing_change_points'; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts index 3c97080e2d655..ea6145b8f6e99 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts @@ -17,7 +17,10 @@ import { parseDuration } from '../../lib'; export async function validateActions( context: RulesClientContext, alertType: UntypedNormalizedRuleType, - data: Pick & { actions: NormalizedAlertAction[] } + data: Pick & { + actions: NormalizedAlertAction[]; + }, + allowMissingConnectorSecrets?: boolean ): Promise { const { actions, notifyWhen, throttle } = data; const hasRuleLevelNotifyWhen = typeof notifyWhen !== 'undefined'; @@ -35,20 +38,26 @@ export async function validateActions( const actionsUsingConnectorsWithMissingSecrets = actionResults.filter( (result) => result.isMissingSecrets ); - if (actionsUsingConnectorsWithMissingSecrets.length) { - errors.push( - i18n.translate('xpack.alerting.rulesClient.validateActions.misconfiguredConnector', { - defaultMessage: 'Invalid connectors: {groups}', - values: { - groups: actionsUsingConnectorsWithMissingSecrets - .map((connector) => connector.name) - .join(', '), - }, - }) - ); + if (allowMissingConnectorSecrets) { + context.logger.error( + `Invalid connectors with "allowMissingConnectorSecrets": ${actionsUsingConnectorsWithMissingSecrets + .map((connector) => connector.name) + .join(', ')}` + ); + } else { + errors.push( + i18n.translate('xpack.alerting.rulesClient.validateActions.misconfiguredConnector', { + defaultMessage: 'Invalid connectors: {groups}', + values: { + groups: actionsUsingConnectorsWithMissingSecrets + .map((connector) => connector.name) + .join(', '), + }, + }) + ); + } } - // check for actions with invalid action groups const { actionGroups: alertTypeActionGroups } = alertType; const usedAlertActionGroups = actions.map((action) => action.group); diff --git a/x-pack/plugins/alerting/server/rules_client/methods/create.ts b/x-pack/plugins/alerting/server/rules_client/methods/create.ts index e8dcefe4a9ef1..09dc8f62494b8 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/create.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/create.ts @@ -46,11 +46,12 @@ export interface CreateOptions { | 'nextRun' > & { actions: NormalizedAlertAction[] }; options?: SavedObjectOptions; + allowMissingConnectorSecrets?: boolean; } export async function create( context: RulesClientContext, - { data, options }: CreateOptions + { data, options, allowMissingConnectorSecrets }: CreateOptions ): Promise> { const id = options?.id || SavedObjectsUtils.generateId(); @@ -104,10 +105,11 @@ export async function create( } } - await validateActions(context, ruleType, data); + await validateActions(context, ruleType, data, allowMissingConnectorSecrets); await withSpan({ name: 'validateActions', type: 'rules' }, () => - validateActions(context, ruleType, data) + validateActions(context, ruleType, data, allowMissingConnectorSecrets) ); + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); if ( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update.ts b/x-pack/plugins/alerting/server/rules_client/methods/update.ts index 8fa3855b95707..5e116f9f21b28 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update.ts @@ -38,22 +38,23 @@ export interface UpdateOptions { throttle?: string | null; notifyWhen?: RuleNotifyWhenType | null; }; + allowMissingConnectorSecrets?: boolean; } export async function update( context: RulesClientContext, - { id, data }: UpdateOptions + { id, data, allowMissingConnectorSecrets }: UpdateOptions ): Promise> { return await retryIfConflicts( context.logger, `rulesClient.update('${id}')`, - async () => await updateWithOCC(context, { id, data }) + async () => await updateWithOCC(context, { id, data, allowMissingConnectorSecrets }) ); } async function updateWithOCC( context: RulesClientContext, - { id, data }: UpdateOptions + { id, data, allowMissingConnectorSecrets }: UpdateOptions ): Promise> { let alertSavedObject: SavedObject; @@ -99,7 +100,11 @@ async function updateWithOCC( context.ruleTypeRegistry.ensureRuleTypeEnabled(alertSavedObject.attributes.alertTypeId); - const updateResult = await updateAlert(context, { id, data }, alertSavedObject); + const updateResult = await updateAlert( + context, + { id, data, allowMissingConnectorSecrets }, + alertSavedObject + ); await Promise.all([ alertSavedObject.attributes.apiKey @@ -138,7 +143,7 @@ async function updateWithOCC( async function updateAlert( context: RulesClientContext, - { id, data }: UpdateOptions, + { id, data, allowMissingConnectorSecrets }: UpdateOptions, { attributes, version }: SavedObject ): Promise> { const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId); @@ -156,7 +161,7 @@ async function updateAlert( // Validate const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate?.params); - await validateActions(context, ruleType, data); + await validateActions(context, ruleType, data, allowMissingConnectorSecrets); // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index c11dc8be21ca4..dddfbe85f9ee3 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -3047,4 +3047,120 @@ describe('create()', () => { expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); + test('should create a rule even if action is missing secret when allowMissingConnectorSecrets is true', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + { + group: 'default', + id: '2', + params: { + foo: true, + }, + }, + ], + }); + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'Slack connector', + isPreconfigured: false, + isDeprecated: false, + }, + ]); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '1m' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'action_1', + type: 'action', + id: '1', + }, + { + name: 'action_2', + type: 'action', + id: '2', + }, + ], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + scheduledTaskId: 'task-123', + }, + references: [], + }); + const result = await rulesClient.create({ data, allowMissingConnectorSecrets: true }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": ".slack", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 44c4eeb50fe27..4d16f3e5d66a0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -2022,6 +2022,155 @@ describe('update()', () => { expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); + test('should update a rule even if action is missing secret when allowMissingConnectorSecrets is true', async () => { + // Reset from default behaviour + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack connector', + isPreconfigured: false, + isDeprecated: false, + }, + ]); + actionsClient.isPreconfigured.mockReset(); + actionsClient.isPreconfigured.mockReturnValueOnce(false); + actionsClient.isPreconfigured.mockReturnValueOnce(true); + actionsClient.isPreconfigured.mockReturnValueOnce(true); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const result = await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + allowMissingConnectorSecrets: true, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + 'alert', + { + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + ], + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + consumer: 'myApp', + enabled: true, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true }, + schedule: { interval: '1m' }, + scheduledTaskId: 'task-123', + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: '1', + overwrite: true, + references: [{ id: '1', name: 'action_0', type: 'action' }], + version: '123', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": ".slack", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "createdAt": 2019-02-12T21:01:22.479Z, + "enabled": true, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { + namespace: 'default', + }); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(1); + }); test('logs warning when creating with an interval less than the minimum configured one when enforce = false', async () => { actionsClient.getBulk.mockReset(); diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index 815665f5f0b43..0cb355aaab572 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -21,6 +21,8 @@ exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`; +exports[`Error CLIENT_GEO_REGION_ISO_CODE 1`] = `undefined`; + exports[`Error CLIENT_GEO_REGION_NAME 1`] = `undefined`; exports[`Error CLOUD 1`] = ` @@ -325,6 +327,8 @@ exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`; +exports[`Span CLIENT_GEO_REGION_ISO_CODE 1`] = `undefined`; + exports[`Span CLIENT_GEO_REGION_NAME 1`] = `undefined`; exports[`Span CLOUD 1`] = ` @@ -612,6 +616,8 @@ exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CLIENT_GEO_COUNTRY_NAME 1`] = `undefined`; +exports[`Transaction CLIENT_GEO_REGION_ISO_CODE 1`] = `undefined`; + exports[`Transaction CLIENT_GEO_REGION_NAME 1`] = `undefined`; exports[`Transaction CLOUD 1`] = ` diff --git a/x-pack/plugins/apm/common/es_fields/apm.ts b/x-pack/plugins/apm/common/es_fields/apm.ts index 6f5420af51c7a..e38d805565c48 100644 --- a/x-pack/plugins/apm/common/es_fields/apm.ts +++ b/x-pack/plugins/apm/common/es_fields/apm.ts @@ -171,6 +171,7 @@ export const EVENT_NAME = 'event.name'; // Location export const CLIENT_GEO_COUNTRY_ISO_CODE = 'client.geo.country_iso_code'; +export const CLIENT_GEO_REGION_ISO_CODE = 'client.geo.region_iso_code'; export const CLIENT_GEO_COUNTRY_NAME = 'client.geo.country_name'; export const CLIENT_GEO_CITY_NAME = 'client.geo.city_name'; export const CLIENT_GEO_REGION_NAME = 'client.geo.region_name'; diff --git a/x-pack/plugins/apm/common/mobile/constants.ts b/x-pack/plugins/apm/common/mobile/constants.ts new file mode 100644 index 0000000000000..d2b8fdc809156 --- /dev/null +++ b/x-pack/plugins/apm/common/mobile/constants.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export enum MapTypes { + Http = 'http_requests', + Session = 'unique_sessions', +} + +export enum MobileSpanSubtype { + Http = 'http', +} + +export enum MobileSpanType { + External = 'external', +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/embedded_map.test.tsx b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/embedded_map.test.tsx similarity index 91% rename from x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/embedded_map.test.tsx rename to x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/embedded_map.test.tsx index 78afdb121df48..88e94ef82a61c 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/embedded_map.test.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/embedded_map.test.tsx @@ -12,12 +12,14 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; import { MemoryRouter } from 'react-router-dom'; +import { MapTypes } from '../../../../../../common/mobile/constants'; describe('Embedded Map', () => { it('it renders', async () => { const mockSetLayerList = jest.fn(); const mockUpdateInput = jest.fn(); const mockRender = jest.fn(); + const mockReload = jest.fn(); const mockEmbeddable = embeddablePluginMock.createStartContract(); mockEmbeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ @@ -25,6 +27,7 @@ describe('Embedded Map', () => { setLayerList: mockSetLayerList, updateInput: mockUpdateInput, render: mockRender, + reload: mockReload, }), })); @@ -37,6 +40,7 @@ describe('Embedded Map', () => { { + const setLayerList = async () => { + if (embeddable && !isErrorEmbeddable(embeddable)) { + const layerList = await getLayerList({ selectedMap, maps }); + await Promise.all([ + embeddable.setLayerList(layerList), + embeddable.reload(), + ]); + } + }; + + setLayerList(); + }, [embeddable, selectedMap, maps]); + useEffect(() => { if (embeddable) { embeddable.updateInput({ @@ -140,7 +153,7 @@ function EmbeddedMapComponent({ }, }); } - }, [start, end, kuery, filters, embeddable]); + }, [start, end, kuery, filters, embeddable, selectedMap]); return ( <> diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/index.tsx new file mode 100644 index 0000000000000..bf8331b69555b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/index.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { Filter } from '@kbn/es-query'; +import { EuiSuperSelect, EuiText } from '@elastic/eui'; +import { EmbeddedMap } from './embedded_map'; +import { MapTypes } from '../../../../../../common/mobile/constants'; + +const availableMaps: Array<{ + id: MapTypes; + label: string; + description: string; +}> = [ + { + id: MapTypes.Http, + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.dropdown.http.requests', + { + defaultMessage: 'HTTP requests', + } + ), + description: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.dropdown.http.requests.subtitle', + { + defaultMessage: + 'HTTP defines a set of request methods to indicate the desired action to be performed for a given resource', + } + ), + }, + { + id: MapTypes.Session, + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.dropdown.sessions', + { + defaultMessage: 'Sessions', + } + ), + description: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.dropdown.sessions.subtitle', + { + defaultMessage: + 'An application session begins when a user starts an application and ends when the application exits.', + } + ), + }, +]; + +export function GeoMap({ + start, + end, + kuery, + filters, +}: { + start: string; + end: string; + kuery?: string; + filters: Filter[]; +}) { + const [selectedMap, setMap] = useState(MapTypes.Http); + + const currentMap = + availableMaps.find(({ id }) => id === selectedMap) ?? availableMaps[0]; + + return ( + <> + + + +

+ {i18n.translate('xpack.apm.serviceOverview.embeddedMap.title', { + defaultMessage: 'Geographic regions', + })} +

+
+ +

+ {i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.subtitle', + { + defaultMessage: + 'Map showing the total number of {currentMap} based on country and region', + values: { currentMap: currentMap.label }, + } + )} +

+
+
+ + ({ + inputDisplay: item.label, + value: item.id, + dropdownDisplay: ( + <> + {item.label} + +

{item.description}

+
+ + ), + }))} + valueOfSelected={selectedMap} + onChange={(value: MapTypes) => setMap(value)} + itemLayoutAlign="top" + hasDividers + /> +
+ + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_http_requests_map_layer_list.ts b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_http_requests_map_layer_list.ts new file mode 100644 index 0000000000000..8954626546290 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_http_requests_map_layer_list.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EMSFileSourceDescriptor, + LayerDescriptor as BaseLayerDescriptor, + VectorLayerDescriptor as BaseVectorLayerDescriptor, + AGG_TYPE, + LAYER_TYPE, + SOURCE_TYPES, +} from '@kbn/maps-plugin/common'; +import { ProcessorEvent } from '@kbn/observability-plugin/common'; +import { v4 as uuidv4 } from 'uuid'; +import type { MapsStartApi } from '@kbn/maps-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + CLIENT_GEO_REGION_ISO_CODE, + PROCESSOR_EVENT, + SPAN_SUBTYPE, + SPAN_TYPE, +} from '../../../../../../../common/es_fields/apm'; +import { APM_STATIC_DATA_VIEW_ID } from '../../../../../../../common/data_view_constants'; +import { getLayerStyle, PalleteColors } from './get_map_layer_style'; +import { + MobileSpanSubtype, + MobileSpanType, +} from '../../../../../../../common/mobile/constants'; + +interface VectorLayerDescriptor extends BaseVectorLayerDescriptor { + sourceDescriptor: EMSFileSourceDescriptor; +} + +const COUNTRY_NAME = 'name'; +const PER_COUNTRY_LAYER_ID = 'per_country'; +const PER_REGION_LAYER_ID = 'per_region'; +const HTTP_REQUEST_PER_COUNTRY = `__kbnjoin__count__${PER_COUNTRY_LAYER_ID}`; +const HTTP_REQUESTS_PER_REGION = `__kbnjoin__count__${PER_REGION_LAYER_ID}`; + +const label = i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.httpRequests.metric.label', + { + defaultMessage: 'HTTP requests', + } +); + +export async function getHttpRequestsLayerList(maps?: MapsStartApi) { + const whereQuery = { + language: 'kuery', + query: `${PROCESSOR_EVENT}:${ProcessorEvent.span} and ${SPAN_SUBTYPE}:${MobileSpanSubtype.Http} and ${SPAN_TYPE}:${MobileSpanType.External}`, + }; + + const basemapLayerDescriptor = + await maps?.createLayerDescriptors?.createBasemapLayerDescriptor(); + + const httpRequestsByCountryLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'iso2', + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: PER_COUNTRY_LAYER_ID, + term: CLIENT_GEO_COUNTRY_ISO_CODE, + metrics: [ + { + type: AGG_TYPE.COUNT, + label, + }, + ], + whereQuery, + indexPatternId: APM_STATIC_DATA_VIEW_ID, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + }, + }, + ], + sourceDescriptor: { + type: SOURCE_TYPES.EMS_FILE, + id: 'world_countries', + tooltipProperties: [COUNTRY_NAME], + }, + style: getLayerStyle(HTTP_REQUEST_PER_COUNTRY, PalleteColors.BluetoRed), + id: uuidv4(), + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.httpRequests.country.label', + { + defaultMessage: 'HTTP requests per country', + } + ), + minZoom: 0, + maxZoom: 2, + alpha: 0.75, + visible: true, + type: LAYER_TYPE.GEOJSON_VECTOR, + }; + + const httpRequestsByRegionLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'region_iso_code', + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: PER_REGION_LAYER_ID, + term: CLIENT_GEO_REGION_ISO_CODE, + metrics: [ + { + type: AGG_TYPE.COUNT, + label, + }, + ], + whereQuery, + indexPatternId: APM_STATIC_DATA_VIEW_ID, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + }, + }, + ], + sourceDescriptor: { + type: SOURCE_TYPES.EMS_FILE, + id: 'administrative_regions_lvl2', + tooltipProperties: ['region_iso_code'], + }, + style: getLayerStyle(HTTP_REQUESTS_PER_REGION, PalleteColors.YellowtoRed), + id: uuidv4(), + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.httpRequests.region.label', + { + defaultMessage: 'HTTP requests per region', + } + ), + minZoom: 1, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: LAYER_TYPE.GEOJSON_VECTOR, + }; + + return [ + ...(basemapLayerDescriptor ? [basemapLayerDescriptor] : []), + httpRequestsByRegionLayer, + httpRequestsByCountryLayer, + ] as BaseLayerDescriptor[]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_layer_list.ts b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_layer_list.ts new file mode 100644 index 0000000000000..152f1874b143b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_layer_list.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { MapsStartApi } from '@kbn/maps-plugin/public'; +import { LayerDescriptor } from '@kbn/maps-plugin/common'; +import { getHttpRequestsLayerList } from './get_http_requests_map_layer_list'; +import { getSessionMapLayerList } from './get_session_map_layer_list'; +import { MapTypes } from '../../../../../../../common/mobile/constants'; + +export async function getLayerList({ + selectedMap, + maps, +}: { + selectedMap: MapTypes; + maps?: MapsStartApi; +}): Promise { + switch (selectedMap) { + case MapTypes.Http: + return await getHttpRequestsLayerList(maps); + case MapTypes.Session: + return await getSessionMapLayerList(maps); + default: + return await getHttpRequestsLayerList(maps); + } +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_map_layer_style.ts b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_map_layer_style.ts new file mode 100644 index 0000000000000..aed6fb2d39b7b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_map_layer_style.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + VectorStyleDescriptor, + COLOR_MAP_TYPE, + FIELD_ORIGIN, + LABEL_BORDER_SIZES, + LABEL_POSITIONS, + STYLE_TYPE, + SYMBOLIZE_AS_TYPES, +} from '@kbn/maps-plugin/common'; + +export enum PalleteColors { + BluetoRed = 'Blue to Red', + YellowtoRed = 'Yellow to Red', +} + +export function getLayerStyle( + fieldName: string, + color: PalleteColors +): VectorStyleDescriptor { + return { + type: 'VECTOR', + properties: { + icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } }, + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + color, + colorCategory: 'palette_0', + fieldMetaOptions: { isEnabled: true, sigma: 3 }, + type: COLOR_MAP_TYPE.ORDINAL, + field: { + name: fieldName, + origin: FIELD_ORIGIN.JOIN, + }, + useCustomColorRamp: false, + }, + }, + lineColor: { + type: STYLE_TYPE.DYNAMIC, + options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } }, + }, + lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, + iconSize: { type: STYLE_TYPE.STATIC, options: { size: 6 } }, + iconOrientation: { + type: STYLE_TYPE.STATIC, + options: { orientation: 0 }, + }, + labelText: { + type: STYLE_TYPE.DYNAMIC, + options: { + field: { + name: fieldName, + origin: FIELD_ORIGIN.JOIN, + }, + }, + }, + labelPosition: { + options: { + position: LABEL_POSITIONS.CENTER, + }, + }, + labelZoomRange: { + options: { + useLayerZoomRange: true, + minZoom: 0, + maxZoom: 24, + }, + }, + labelColor: { + type: STYLE_TYPE.STATIC, + options: { color: '#3d3d3d' }, + }, + labelSize: { type: STYLE_TYPE.STATIC, options: { size: 14 } }, + labelBorderColor: { + type: STYLE_TYPE.STATIC, + options: { color: '#FFFFFF' }, + }, + symbolizeAs: { options: { value: SYMBOLIZE_AS_TYPES.CIRCLE } }, + labelBorderSize: { options: { size: LABEL_BORDER_SIZES.SMALL } }, + }, + isTimeAware: true, + }; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_session_map_layer_list.ts b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_session_map_layer_list.ts new file mode 100644 index 0000000000000..a0d2b80b218fd --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/geo_map/map_layers/get_session_map_layer_list.ts @@ -0,0 +1,136 @@ +/* + * Copyright 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 { + EMSFileSourceDescriptor, + LayerDescriptor as BaseLayerDescriptor, + VectorLayerDescriptor as BaseVectorLayerDescriptor, + AGG_TYPE, + LAYER_TYPE, + SOURCE_TYPES, +} from '@kbn/maps-plugin/common'; +import { v4 as uuidv4 } from 'uuid'; +import type { MapsStartApi } from '@kbn/maps-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { + CLIENT_GEO_COUNTRY_ISO_CODE, + CLIENT_GEO_REGION_ISO_CODE, + SESSION_ID, +} from '../../../../../../../common/es_fields/apm'; +import { APM_STATIC_DATA_VIEW_ID } from '../../../../../../../common/data_view_constants'; +import { getLayerStyle, PalleteColors } from './get_map_layer_style'; + +interface VectorLayerDescriptor extends BaseVectorLayerDescriptor { + sourceDescriptor: EMSFileSourceDescriptor; +} + +const PER_COUNTRY_LAYER_ID = 'per_country'; +const PER_REGION_LAYER_ID = 'per_region'; +const COUNTRY_NAME = 'name'; +const SESSION_PER_COUNTRY = `__kbnjoin__cardinality_of_session.id__${PER_COUNTRY_LAYER_ID}`; +const SESSION_PER_REGION = `__kbnjoin__cardinality_of_session.id__${PER_REGION_LAYER_ID}`; + +const label = i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.session.metric.label', + { + defaultMessage: 'Sessions', + } +); +export async function getSessionMapLayerList(maps?: MapsStartApi) { + const basemapLayerDescriptor = + await maps?.createLayerDescriptors?.createBasemapLayerDescriptor(); + + const sessionsByCountryLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'iso2', + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: PER_COUNTRY_LAYER_ID, + term: CLIENT_GEO_COUNTRY_ISO_CODE, + metrics: [ + { + type: AGG_TYPE.UNIQUE_COUNT, + field: SESSION_ID, + label, + }, + ], + indexPatternId: APM_STATIC_DATA_VIEW_ID, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + }, + }, + ], + sourceDescriptor: { + type: SOURCE_TYPES.EMS_FILE, + id: 'world_countries', + tooltipProperties: [COUNTRY_NAME], + }, + style: getLayerStyle(SESSION_PER_COUNTRY, PalleteColors.BluetoRed), + id: uuidv4(), + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.sessionCountry.metric.label', + { + defaultMessage: 'Sessions per country', + } + ), + minZoom: 0, + maxZoom: 2, + alpha: 0.75, + visible: true, + type: LAYER_TYPE.GEOJSON_VECTOR, + }; + + const sessionsByRegionLayer: VectorLayerDescriptor = { + joins: [ + { + leftField: 'region_iso_code', + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + id: PER_REGION_LAYER_ID, + term: CLIENT_GEO_REGION_ISO_CODE, + metrics: [ + { + type: AGG_TYPE.UNIQUE_COUNT, + field: SESSION_ID, + label, + }, + ], + indexPatternId: APM_STATIC_DATA_VIEW_ID, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + }, + }, + ], + sourceDescriptor: { + type: SOURCE_TYPES.EMS_FILE, + id: 'administrative_regions_lvl2', + tooltipProperties: ['region_iso_code'], + }, + style: getLayerStyle(SESSION_PER_REGION, PalleteColors.YellowtoRed), + id: uuidv4(), + label: i18n.translate( + 'xpack.apm.serviceOverview.embeddedMap.sessionRegion.metric.label', + { + defaultMessage: 'Sessions per region', + } + ), + minZoom: 1, + maxZoom: 24, + alpha: 0.75, + visible: true, + type: LAYER_TYPE.GEOJSON_VECTOR, + }; + + return [ + ...(basemapLayerDescriptor ? [basemapLayerDescriptor] : []), + sessionsByRegionLayer, + sessionsByCountryLayer, + ] as BaseLayerDescriptor[]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/service_overview/index.tsx index 326a7ae22f085..6e0f786aaf731 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/service_overview/index.tsx @@ -35,7 +35,7 @@ import { SERVICE_VERSION, } from '../../../../../common/es_fields/apm'; import { MostUsedChart } from './most_used_chart'; -import { LatencyMap } from './latency_map'; +import { GeoMap } from './geo_map'; import { FailedTransactionRateChart } from '../../../shared/charts/failed_transaction_rate_chart'; import { ServiceOverviewDependenciesTable } from '../../service_overview/service_overview_dependencies_table'; import { LatencyChart } from '../../../shared/charts/latency_chart'; @@ -65,7 +65,6 @@ export function MobileServiceOverview() { osVersion, appVersion, netConnectionType, - comparisonEnabled, }, } = useApmParams('/mobile-services/{serviceName}/overview'); @@ -155,12 +154,11 @@ export function MobileServiceOverview() { - diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/get_layer_list.ts b/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/get_layer_list.ts deleted file mode 100644 index d6f544cdd1d21..0000000000000 --- a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/get_layer_list.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EMSFileSourceDescriptor, - LayerDescriptor as BaseLayerDescriptor, - VectorLayerDescriptor as BaseVectorLayerDescriptor, - VectorStyleDescriptor, - AGG_TYPE, - COLOR_MAP_TYPE, - FIELD_ORIGIN, - LABEL_BORDER_SIZES, - LABEL_POSITIONS, - LAYER_TYPE, - SOURCE_TYPES, - STYLE_TYPE, - SYMBOLIZE_AS_TYPES, -} from '@kbn/maps-plugin/common'; -import { v4 as uuidv4 } from 'uuid'; -import type { MapsStartApi } from '@kbn/maps-plugin/public'; -import { i18n } from '@kbn/i18n'; -import { - CLIENT_GEO_COUNTRY_ISO_CODE, - TRANSACTION_DURATION, -} from '../../../../../../common/es_fields/apm'; -import { APM_STATIC_DATA_VIEW_ID } from '../../../../../../common/data_view_constants'; - -interface VectorLayerDescriptor extends BaseVectorLayerDescriptor { - sourceDescriptor: EMSFileSourceDescriptor; -} - -const FIELD_NAME = 'apm-service-overview-layer-country'; -const COUNTRY_NAME = 'name'; -const TRANSACTION_DURATION_COUNTRY = `__kbnjoin__avg_of_transaction.duration.us__${FIELD_NAME}`; - -function getLayerStyle(): VectorStyleDescriptor { - return { - type: 'VECTOR', - properties: { - icon: { type: STYLE_TYPE.STATIC, options: { value: 'marker' } }, - fillColor: { - type: STYLE_TYPE.DYNAMIC, - options: { - color: 'Blue to Red', - colorCategory: 'palette_0', - fieldMetaOptions: { isEnabled: true, sigma: 3 }, - type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: TRANSACTION_DURATION_COUNTRY, - origin: FIELD_ORIGIN.JOIN, - }, - useCustomColorRamp: false, - }, - }, - lineColor: { - type: STYLE_TYPE.DYNAMIC, - options: { color: '#3d3d3d', fieldMetaOptions: { isEnabled: true } }, - }, - lineWidth: { type: STYLE_TYPE.STATIC, options: { size: 1 } }, - iconSize: { type: STYLE_TYPE.STATIC, options: { size: 6 } }, - iconOrientation: { - type: STYLE_TYPE.STATIC, - options: { orientation: 0 }, - }, - labelText: { - type: STYLE_TYPE.DYNAMIC, - options: { - field: { - name: TRANSACTION_DURATION_COUNTRY, - origin: FIELD_ORIGIN.JOIN, - }, - }, - }, - labelPosition: { - options: { - position: LABEL_POSITIONS.CENTER, - }, - }, - labelZoomRange: { - options: { - useLayerZoomRange: true, - minZoom: 0, - maxZoom: 24, - }, - }, - labelColor: { - type: STYLE_TYPE.STATIC, - options: { color: '#000000' }, - }, - labelSize: { type: STYLE_TYPE.STATIC, options: { size: 14 } }, - labelBorderColor: { - type: STYLE_TYPE.STATIC, - options: { color: '#FFFFFF' }, - }, - symbolizeAs: { options: { value: SYMBOLIZE_AS_TYPES.CIRCLE } }, - labelBorderSize: { options: { size: LABEL_BORDER_SIZES.SMALL } }, - }, - isTimeAware: true, - }; -} - -export async function getLayerList(maps?: MapsStartApi) { - const basemapLayerDescriptor = maps - ? await maps.createLayerDescriptors.createBasemapLayerDescriptor() - : null; - - const pageLoadDurationByCountryLayer: VectorLayerDescriptor = { - joins: [ - { - leftField: 'iso2', - right: { - type: SOURCE_TYPES.ES_TERM_SOURCE, - id: FIELD_NAME, - term: CLIENT_GEO_COUNTRY_ISO_CODE, - metrics: [ - { - type: AGG_TYPE.AVG, - field: TRANSACTION_DURATION, - label: i18n.translate( - 'xpack.apm.serviceOverview.embeddedMap.metric.label', - { - defaultMessage: 'Page load duration', - } - ), - }, - ], - indexPatternId: APM_STATIC_DATA_VIEW_ID, - applyGlobalQuery: true, - applyGlobalTime: true, - applyForceRefresh: true, - }, - }, - ], - sourceDescriptor: { - type: SOURCE_TYPES.EMS_FILE, - id: 'world_countries', - tooltipProperties: [COUNTRY_NAME], - }, - style: getLayerStyle(), - id: uuidv4(), - label: null, - minZoom: 0, - maxZoom: 24, - alpha: 0.75, - visible: true, - type: LAYER_TYPE.GEOJSON_VECTOR, - }; - - return [ - ...(basemapLayerDescriptor ? [basemapLayerDescriptor] : []), - pageLoadDurationByCountryLayer, - ] as BaseLayerDescriptor[]; -} diff --git a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/index.tsx deleted file mode 100644 index 6ac672dea1f06..0000000000000 --- a/x-pack/plugins/apm/public/components/app/mobile/service_overview/latency_map/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiIconTip, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import type { Filter } from '@kbn/es-query'; -import { EmbeddedMap } from './embedded_map'; - -export function LatencyMap({ - start, - end, - kuery, - filters, - comparisonEnabled, -}: { - start: string; - end: string; - kuery?: string; - filters: Filter[]; - comparisonEnabled: boolean; -}) { - return ( - <> - - - -

- {i18n.translate('xpack.apm.serviceOverview.embeddedMap.title', { - defaultMessage: 'Average latency per country', - })} -

-
-
- - {comparisonEnabled && ( - - )} - -
- - - - ); -} diff --git a/x-pack/plugins/apm/public/hooks/use_filters_for_embeddable_charts.ts b/x-pack/plugins/apm/public/hooks/use_filters_for_embeddable_charts.ts index 22dbf955b2308..b95d569ca8415 100644 --- a/x-pack/plugins/apm/public/hooks/use_filters_for_embeddable_charts.ts +++ b/x-pack/plugins/apm/public/hooks/use_filters_for_embeddable_charts.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { SERVICE_NAME, TRANSACTION_TYPE } from '../../common/es_fields/apm'; +import { SERVICE_NAME } from '../../common/es_fields/apm'; import { termQuery } from '../../common/utils/term_query'; import { useApmParams } from './use_apm_params'; import { environmentQuery } from '../../common/utils/environment_query'; @@ -14,19 +14,18 @@ import { environmentQuery } from '../../common/utils/environment_query'; export function useFiltersForEmbeddableCharts() { const { path: { serviceName }, - query: { environment, transactionType }, + query: { environment }, } = useApmParams('/mobile-services/{serviceName}/overview'); return useMemo( () => [ ...termQuery(SERVICE_NAME, serviceName), - ...termQuery(TRANSACTION_TYPE, transactionType), ...environmentQuery(environment), ].map((query) => ({ meta: {}, query, })), - [environment, transactionType, serviceName] + [environment, serviceName] ); } diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_http_requests.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_http_requests.ts index f5821692c11d6..9ec1dc3946e1b 100644 --- a/x-pack/plugins/apm/server/routes/mobile/get_mobile_http_requests.ts +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_http_requests.ts @@ -17,11 +17,16 @@ import { SPAN_TYPE, SPAN_SUBTYPE, } from '../../../common/es_fields/apm'; +import { + MobileSpanSubtype, + MobileSpanType, +} from '../../../common/mobile/constants'; import { environmentQuery } from '../../../common/utils/environment_query'; import { getBucketSize } from '../../../common/utils/get_bucket_size'; import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; import { Maybe } from '../../../typings/common'; + import { Coordinate } from '../../../typings/timeseries'; import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; @@ -64,7 +69,7 @@ async function getHttpRequestsTimeseries({ const aggs = { requests: { - filter: { term: { [SPAN_SUBTYPE]: 'http' } }, + filter: { term: { [SPAN_SUBTYPE]: MobileSpanSubtype.Http } }, }, }; @@ -76,7 +81,7 @@ async function getHttpRequestsTimeseries({ query: { bool: { filter: [ - { exists: { field: SPAN_SUBTYPE } }, + ...termQuery(SPAN_TYPE, MobileSpanType.External), ...termQuery(SERVICE_NAME, serviceName), ...termQuery(SPAN_TYPE, 'external'), ...termQuery(TRANSACTION_NAME, transactionName), diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index 2ecc7143f4a89..4eaf633fb98c7 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -8,7 +8,7 @@ import * as rt from 'io-ts'; import { NumberFromString } from '../saved_object'; -import { UserRT } from '../user'; +import { UserRt } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt } from '../connectors/connector'; @@ -97,7 +97,7 @@ export const CaseUserActionExternalServiceRt = rt.type({ external_title: rt.string, external_url: rt.string, pushed_at: rt.string, - pushed_by: UserRT, + pushed_by: UserRt, }); export const CaseExternalServiceBasicRt = rt.intersection([ @@ -114,12 +114,12 @@ export const CaseAttributesRt = rt.intersection([ rt.type({ duration: rt.union([rt.number, rt.null]), closed_at: rt.union([rt.string, rt.null]), - closed_by: rt.union([UserRT, rt.null]), + closed_by: rt.union([UserRt, rt.null]), created_at: rt.string, - created_by: UserRT, + created_by: UserRt, external_service: CaseFullExternalServiceRt, updated_at: rt.union([rt.string, rt.null]), - updated_by: rt.union([UserRT, rt.null]), + updated_by: rt.union([UserRt, rt.null]), }), ]); diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index b768b8f0b593f..2788d1c4022d2 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -9,16 +9,16 @@ import * as rt from 'io-ts'; import { jsonValueRt } from '../runtime_types'; import { SavedObjectFindOptionsRt } from '../saved_object'; -import { UserRT } from '../user'; +import { UserRt } from '../user'; export const CommentAttributesBasicRt = rt.type({ created_at: rt.string, - created_by: UserRT, + created_by: UserRt, owner: rt.string, pushed_at: rt.union([rt.string, rt.null]), - pushed_by: rt.union([UserRT, rt.null]), + pushed_by: rt.union([UserRt, rt.null]), updated_at: rt.union([rt.string, rt.null]), - updated_by: rt.union([UserRT, rt.null]), + updated_by: rt.union([UserRt, rt.null]), }); export enum CommentType { diff --git a/x-pack/plugins/cases/common/api/cases/configure.ts b/x-pack/plugins/cases/common/api/cases/configure.ts index bf67624df8508..e6f62a1b8dd01 100644 --- a/x-pack/plugins/cases/common/api/cases/configure.ts +++ b/x-pack/plugins/cases/common/api/cases/configure.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -import { UserRT } from '../user'; +import { UserRt } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connectors'; // TODO: we will need to add this type rt.literal('close-by-third-party') @@ -44,9 +44,9 @@ export const CaseConfigureAttributesRt = rt.intersection([ CasesConfigureBasicRt, rt.type({ created_at: rt.string, - created_by: UserRT, + created_by: UserRt, updated_at: rt.union([rt.string, rt.null]), - updated_by: rt.union([UserRT, rt.null]), + updated_by: rt.union([UserRt, rt.null]), }), ]); diff --git a/x-pack/plugins/cases/common/api/cases/user_actions/common.ts b/x-pack/plugins/cases/common/api/cases/user_actions/common.ts index 271eec5c6f363..fac254e33094a 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions/common.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions/common.ts @@ -6,7 +6,7 @@ */ import * as rt from 'io-ts'; -import { UserRT } from '../../user'; +import { UserRt } from '../../user'; /** * These values are used in a number of places including to define the accepted values in the @@ -42,7 +42,7 @@ export const ActionsRt = rt.keyof(Actions); export const UserActionCommonAttributesRt = rt.type({ created_at: rt.string, - created_by: UserRT, + created_by: UserRt, owner: rt.string, action: ActionsRt, }); diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 746f2bd6972ab..db5a333c7546e 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -19,6 +19,7 @@ import { INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL, INTERNAL_BULK_GET_ATTACHMENTS_URL, INTERNAL_CONNECTORS_URL, + INTERNAL_CASE_USERS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -72,3 +73,7 @@ export const getCaseBulkGetAttachmentsUrl = (id: string): string => { export const getCaseConnectorsUrl = (id: string): string => { return INTERNAL_CONNECTORS_URL.replace('{case_id}', id); }; + +export const getCaseUsersUrl = (id: string): string => { + return INTERNAL_CASE_USERS_URL.replace('{case_id}', id); +}; diff --git a/x-pack/plugins/cases/common/api/user.ts b/x-pack/plugins/cases/common/api/user.ts index 63280d230b777..420e3248faa9c 100644 --- a/x-pack/plugins/cases/common/api/user.ts +++ b/x-pack/plugins/cases/common/api/user.ts @@ -7,7 +7,7 @@ import * as rt from 'io-ts'; -export const UserRT = rt.intersection([ +export const UserRt = rt.intersection([ rt.type({ email: rt.union([rt.undefined, rt.null, rt.string]), full_name: rt.union([rt.undefined, rt.null, rt.string]), @@ -16,6 +16,14 @@ export const UserRT = rt.intersection([ rt.partial({ profile_uid: rt.string }), ]); -export const UsersRt = rt.array(UserRT); +export const UsersRt = rt.array(UserRt); -export type User = rt.TypeOf; +export type User = rt.TypeOf; + +export const GetCaseUsersResponseRt = rt.type({ + assignees: rt.array(UserRt), + unassignedUsers: rt.array(UserRt), + participants: rt.array(UserRt), +}); + +export type GetCaseUsersResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index c359353f80b80..601599104641b 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -96,6 +96,7 @@ export const INTERNAL_CONNECTORS_URL = `${CASES_INTERNAL_URL}/{case_id}/_connect export const INTERNAL_BULK_GET_CASES_URL = `${CASES_INTERNAL_URL}/_bulk_get` as const; export const INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL = `${CASES_INTERNAL_URL}/{case_id}/user_actions/_stats` as const; +export const INTERNAL_CASE_USERS_URL = `${CASES_INTERNAL_URL}/{case_id}/_users` as const; /** * Action routes @@ -119,18 +120,21 @@ export const GENERAL_CASES_OWNER = APP_ID; export const OWNER_INFO = { [SECURITY_SOLUTION_OWNER]: { + id: SECURITY_SOLUTION_OWNER, appId: 'securitySolutionUI', label: 'Security', iconType: 'logoSecurity', appRoute: '/app/security', }, [OBSERVABILITY_OWNER]: { + id: OBSERVABILITY_OWNER, appId: 'observability-overview', label: 'Observability', iconType: 'logoObservability', appRoute: '/app/observability', }, [GENERAL_CASES_OWNER]: { + id: GENERAL_CASES_OWNER, appId: 'management', label: 'Stack', iconType: 'casesApp', diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 909bb1dd24ea0..e1e6e5ec42de8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -426,6 +426,20 @@ describe.skip('AllCasesListGeneric', () => { }); }); + it('should render only Name, CreatedOn and Severity columns when isSelectorView=true', async () => { + const wrapper = mount( + + + + ); + await waitFor(() => { + expect(wrapper.find('[data-test-subj="tableHeaderCell_title_0"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_createdAt_1"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_severity_2"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tableHeaderCell_assignees_1"]').exists()).toBe(false); + }); + }); + it('should sort by severity', async () => { const result = appMockRenderer.render(); @@ -698,12 +712,12 @@ describe.skip('AllCasesListGeneric', () => { queryParams: DEFAULT_QUERY_PARAMS, }); - userEvent.click(getByTestId('options-filter-popover-button-Solution')); + userEvent.click(getByTestId('solution-filter-popover-button')); await waitForEuiPopoverOpen(); userEvent.click( - getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), + getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), undefined, { skipPointerEventsCheck: true, @@ -725,7 +739,7 @@ describe.skip('AllCasesListGeneric', () => { }); userEvent.click( - getByTestId(`options-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), + getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`), undefined, { skipPointerEventsCheck: true, @@ -754,7 +768,7 @@ describe.skip('AllCasesListGeneric', () => { ); - expect(queryByTestId('options-filter-popover-button-Solution')).toBeFalsy(); + expect(queryByTestId('solution-filter-popover-button')).toBeFalsy(); }); it('should call useGetCases with the correct owner on initial render', async () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index ab50f16083f8b..7666d518fe9b4 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -14,11 +14,13 @@ import styled, { css } from 'styled-components'; import type { Case, CaseStatusWithAllStatus, FilterOptions } from '../../../common/ui/types'; import { SortFieldCase, StatusAll } from '../../../common/ui/types'; import { CaseStatuses, caseStatuses } from '../../../common/api'; +import { OWNER_INFO } from '../../../common/constants'; +import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './use_cases_columns'; import { CasesTableFilters } from './table_filters'; -import type { EuiBasicTableOnChange } from './types'; +import type { EuiBasicTableOnChange, Solution } from './types'; import { CasesTable } from './table'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -48,6 +50,17 @@ const getSortField = (field: string): SortFieldCase => // @ts-ignore SortFieldCase[field] ?? SortFieldCase.title; +const isValidSolution = (solution: string): solution is CasesOwners => + Object.keys(OWNER_INFO).includes(solution); + +const mapToReadableSolutionName = (solution: string): Solution => { + if (isValidSolution(solution)) { + return OWNER_INFO[solution]; + } + + return { id: solution, label: solution, iconType: '' }; +}; + export interface AllCasesListProps { hiddenStatuses?: CaseStatusWithAllStatus[]; isSelectorView?: boolean; @@ -228,6 +241,10 @@ export const AllCasesList = React.memo( [] ); + const availableSolutionsLabels = availableSolutions.map((solution) => + mapToReadableSolutionName(solution) + ); + return ( <> ( countOpenCases={data.countOpenCases} countInProgressCases={data.countInProgressCases} onFilterChanged={onFilterChangedCallback} - availableSolutions={hasOwner ? [] : availableSolutions} + availableSolutions={hasOwner ? [] : availableSolutionsLabels} initial={{ search: filterOptions.search, searchFields: filterOptions.searchFields, @@ -254,8 +271,8 @@ export const AllCasesList = React.memo( severity: filterOptions.severity, }} hiddenStatuses={hiddenStatuses} - displayCreateCaseButton={isSelectorView} onCreateCasePressed={onRowClick} + isSelectorView={isSelectorView} isLoading={isLoadingCurrentUserProfile} currentUserProfile={currentUserProfile} /> diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx index 0ba5a65bf3207..c04ea59dfebc5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/all_cases_selector_modal.tsx @@ -7,7 +7,7 @@ import React, { useState, useCallback } from 'react'; import { - EuiButton, + EuiButtonEmpty, EuiModal, EuiModalBody, EuiModalFooter, @@ -29,7 +29,7 @@ export interface AllCasesSelectorModalProps { const Modal = styled(EuiModal)` ${({ theme }) => ` - min-width: ${theme.eui.euiBreakpoints.l}; + min-width: ${theme.eui.euiBreakpoints.m}; max-width: ${theme.eui.euiBreakpoints.xl}; `} `; @@ -68,13 +68,13 @@ export const AllCasesSelectorModal = React.memo( /> - {i18n.CANCEL} - + diff --git a/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx new file mode 100644 index 0000000000000..dcb469448b03f --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/solution_filter.test.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { Solution } from './types'; +import { + OWNER_INFO, + SECURITY_SOLUTION_OWNER, + OBSERVABILITY_OWNER, +} from '../../../common/constants'; + +import { SolutionFilter } from './solution_filter'; +import userEvent from '@testing-library/user-event'; + +describe('SolutionFilter ', () => { + let appMockRender: AppMockRenderer; + const onSelectedOptionsChanged = jest.fn(); + const solutions: Solution[] = [ + { + id: SECURITY_SOLUTION_OWNER, + label: OWNER_INFO[SECURITY_SOLUTION_OWNER].label, + iconType: OWNER_INFO[SECURITY_SOLUTION_OWNER].iconType, + }, + { + id: OBSERVABILITY_OWNER, + label: OWNER_INFO[OBSERVABILITY_OWNER].label, + iconType: OWNER_INFO[OBSERVABILITY_OWNER].iconType, + }, + ]; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders button correctly', () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('solution-filter-popover-button')).toBeInTheDocument(); + }); + + it('renders empty label correctly', async () => { + const { getByTestId, getByText } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + expect(getByText('No options available')).toBeInTheDocument(); + }); + + it('renders options correctly', async () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('solution-filter-popover-button')).toBeInTheDocument(); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + expect(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)).toBeInTheDocument(); + expect(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)).toBeInTheDocument(); + }); + + it('should call onSelectionChange with selected solution id', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`solution-filter-popover-item-${solutions[0].id}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([solutions[0].id]); + }); + + it('should call onSelectionChange with empty array when solution option is deselected', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`solution-filter-popover-item-${solutions[1].id}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx new file mode 100644 index 0000000000000..b776895e2fe9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/solution_filter.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiPopover, + EuiText, + EuiIcon, +} from '@elastic/eui'; +import styled from 'styled-components'; + +import * as i18n from './translations'; +import type { Solution } from './types'; + +interface FilterPopoverProps { + onSelectedOptionsChanged: (value: string[]) => void; + options: Solution[]; + optionsEmptyLabel?: string; + selectedOptions: string[]; +} + +const ScrollableDiv = styled.div` + max-height: 250px; + overflow: auto; +`; + +const toggleSelectedGroup = (group: string, selectedGroups: string[]): string[] => { + const selectedGroupIndex = selectedGroups.indexOf(group); + if (selectedGroupIndex >= 0) { + return [ + ...selectedGroups.slice(0, selectedGroupIndex), + ...selectedGroups.slice(selectedGroupIndex + 1), + ]; + } + return [...selectedGroups, group]; +}; + +/** + * Popover for selecting a field to filter on + * + * @param buttonLabel label on dropdwon button + * @param onSelectedOptionsChanged change listener to be notified when option selection changes + * @param options to display for filtering + * @param optionsEmptyLabel shows when options empty + * @param selectedOptions manage state of selectedOptions + */ +export const SolutionFilterComponent = ({ + onSelectedOptionsChanged, + options, + optionsEmptyLabel, + selectedOptions, +}: FilterPopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const setIsPopoverOpenCb = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]); + const toggleSelectedGroupCb = useCallback( + (option) => onSelectedOptionsChanged(toggleSelectedGroup(option, selectedOptions)), + [selectedOptions, onSelectedOptionsChanged] + ); + + return ( + 0} + numActiveFilters={selectedOptions.length} + aria-label={i18n.SOLUTION} + > + {i18n.SOLUTION} + + } + isOpen={isPopoverOpen} + closePopover={setIsPopoverOpenCb} + panelPaddingSize="none" + repositionOnScroll + > + + {options.map((option, index) => ( + + + + + + {option.label} + + + ))} + + {options.length === 0 && optionsEmptyLabel != null && ( + + + + {optionsEmptyLabel} + + + + )} + + ); +}; + +SolutionFilterComponent.displayName = 'SolutionFilterComponent'; + +export const SolutionFilter = React.memo(SolutionFilterComponent); + +SolutionFilter.displayName = 'SolutionFilter'; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 6c0ce7569b1c6..ccfcf715a67ba 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -6,16 +6,20 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { waitForComponentToUpdate } from '../../common/test_utils'; import { CaseStatuses } from '../../../common/api'; -import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { + OWNER_INFO, + SECURITY_SOLUTION_OWNER, + OBSERVABILITY_OWNER, +} from '../../../common/constants'; import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { CasesTableFilters } from './table_filters'; import { useGetTags } from '../../containers/use_get_tags'; @@ -52,37 +56,31 @@ describe('CasesTableFilters ', () => { }); it('should render the case status filter dropdown', () => { - const wrapper = mount( - - - - ); + appMockRender.render(); - expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy(); + expect(screen.getByTestId('case-status-filter')).toBeInTheDocument(); }); it('should render the case severity filter dropdown', () => { - const result = appMockRender.render(); - expect(result.getByTestId('case-severity-filter')).toBeTruthy(); + appMockRender.render(); + expect(screen.getByTestId('case-severity-filter')).toBeTruthy(); }); it('should call onFilterChange when the severity filter changes', async () => { - const result = appMockRender.render(); - userEvent.click(result.getByTestId('case-severity-filter')); + appMockRender.render(); + userEvent.click(screen.getByTestId('case-severity-filter')); await waitForEuiPopoverOpen(); - userEvent.click(result.getByTestId('case-severity-filter-high')); + userEvent.click(screen.getByTestId('case-severity-filter-high')); expect(onFilterChanged).toBeCalledWith({ severity: 'high' }); }); - it('should call onFilterChange when selected tags change', () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="options-filter-popover-button-Tags"]`).last().simulate('click'); - wrapper.find(`[data-test-subj="options-filter-popover-item-coke"]`).last().simulate('click'); + it('should call onFilterChange when selected tags change', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('options-filter-popover-item-coke')); expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] }); }); @@ -109,29 +107,21 @@ describe('CasesTableFilters ', () => { `); }); - it('should call onFilterChange when search changes', () => { - const wrapper = mount( - - - - ); - - wrapper - .find(`[data-test-subj="search-cases"]`) - .last() - .simulate('keyup', { key: 'Enter', target: { value: 'My search' } }); + it('should call onFilterChange when search changes', async () => { + appMockRender.render(); + + await userEvent.type(screen.getByTestId('search-cases'), 'My search{enter}'); + expect(onFilterChanged).toBeCalledWith({ search: 'My search' }); }); - it('should call onFilterChange when changing status', () => { - const wrapper = mount( - - - - ); + it('should call onFilterChange when changing status', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('case-status-filter')); + await waitForEuiPopoverOpen(); + userEvent.click(screen.getByTestId('case-status-filter-closed')); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); expect(onFilterChanged).toBeCalledWith({ status: CaseStatuses.closed }); }); @@ -143,11 +133,8 @@ describe('CasesTableFilters ', () => { tags: ['pepsi', 'rc'], }, }; - mount( - - - - ); + + appMockRender.render(); expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); }); @@ -186,165 +173,104 @@ describe('CasesTableFilters ', () => { }); it('StatusFilterWrapper should have a fixed width of 180px', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find('[data-test-subj="status-filter-wrapper"]').first()).toHaveStyleRule( - 'flex-basis', - '180px', - { - modifier: '&&', - } - ); + appMockRender.render(); + + expect(screen.getByTestId('status-filter-wrapper')).toHaveStyleRule('flex-basis', '180px', { + modifier: '&&', + }); }); describe('Solution filter', () => { + const securitySolution = { + id: SECURITY_SOLUTION_OWNER, + label: OWNER_INFO[SECURITY_SOLUTION_OWNER].label, + iconType: OWNER_INFO[SECURITY_SOLUTION_OWNER].iconType, + }; + const observabilitySolution = { + id: OBSERVABILITY_OWNER, + label: OWNER_INFO[OBSERVABILITY_OWNER].label, + iconType: OWNER_INFO[OBSERVABILITY_OWNER].iconType, + }; + it('shows Solution filter when provided more than 1 availableSolutions', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() - ).toBeTruthy(); + expect(screen.getByTestId('solution-filter-popover-button')).toBeInTheDocument(); }); it('does not show Solution filter when provided less than 1 availableSolutions', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).exists() - ).toBeFalsy(); + expect(screen.queryByTestId('solution-filter-popover-button')).not.toBeInTheDocument(); }); - it('should call onFilterChange when selected solution changes', () => { - const wrapper = mount( - - - + it('should call onFilterChange when selected solution changes', async () => { + appMockRender.render( + ); - wrapper - .find(`[data-test-subj="options-filter-popover-button-Solution"]`) - .last() - .simulate('click'); + userEvent.click(screen.getByTestId('solution-filter-popover-button')); + + await waitForEuiPopoverOpen(); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [SECURITY_SOLUTION_OWNER] }); }); - it('should deselect all solutions', () => { - const wrapper = mount( - - - + it('should deselect all solutions', async () => { + appMockRender.render( + ); - wrapper - .find(`[data-test-subj="options-filter-popover-button-Solution"]`) - .last() - .simulate('click'); + userEvent.click(screen.getByTestId('solution-filter-popover-button')); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + await waitForEuiPopoverOpen(); + + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [SECURITY_SOLUTION_OWNER] }); - wrapper - .find(`[data-test-subj="options-filter-popover-item-${SECURITY_SOLUTION_OWNER}"]`) - .last() - .simulate('click'); + userEvent.click( + screen.getByTestId(`solution-filter-popover-item-${SECURITY_SOLUTION_OWNER}`) + ); expect(onFilterChanged).toBeCalledWith({ owner: [] }); }); it('does not select a solution on initial render', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper.find(`[data-test-subj="options-filter-popover-button-Solution"]`).first().props() - ).toEqual(expect.objectContaining({ hasActiveFilters: false })); - }); - }); - - describe('create case button', () => { - it('should not render the create case button when displayCreateCaseButton and onCreateCasePressed are not passed', () => { - const wrapper = mount( - - - + appMockRender.render( + ); - expect(wrapper.find(`[data-test-subj="cases-table-add-case-filter-bar"]`).length).toBe(0); - }); - - it('should render the create case button when displayCreateCaseButton and onCreateCasePressed are passed', () => { - const onCreateCasePressed = jest.fn(); - const wrapper = mount( - - - - ); - expect(wrapper.find(`[data-test-subj="cases-table-add-case-filter-bar"]`)).toBeTruthy(); - }); - it('should call the onCreateCasePressed when create case is clicked', () => { - const onCreateCasePressed = jest.fn(); - const wrapper = mount( - - - + expect(screen.getByTestId('solution-filter-popover-button')).not.toHaveAttribute( + 'hasActiveFilters' ); - wrapper - .find(`button[data-test-subj="cases-table-add-case-filter-bar"]`) - .first() - .simulate('click'); - wrapper.update(); - // NOTE: intentionally checking no arguments are passed - expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); describe('assignees filter', () => { it('should hide the assignees filters on basic license', async () => { - const result = appMockRender.render(); + appMockRender.render(); - expect(result.queryByTestId('options-filter-popover-button-assignees')).toBeNull(); + expect(screen.queryByTestId('options-filter-popover-button-assignees')).toBeNull(); }); it('should show the assignees filters on platinum license', async () => { @@ -353,9 +279,45 @@ describe('CasesTableFilters ', () => { }); appMockRender = createAppMockRenderer({ license }); - const result = appMockRender.render(); + appMockRender.render(); + + expect(screen.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + }); + }); + + describe('create case button', () => { + it('should not render the create case button when isSelectorView is false and onCreateCasePressed are not passed', () => { + appMockRender.render(); + expect(screen.queryByTestId('cases-table-add-case-filter-bar')).not.toBeInTheDocument(); + }); + + it('should render the create case button when isSelectorView is true and onCreateCasePressed are passed', () => { + const onCreateCasePressed = jest.fn(); + appMockRender.render( + + ); + expect(screen.getByTestId('cases-table-add-case-filter-bar')).toBeInTheDocument(); + }); - expect(result.getByTestId('options-filter-popover-button-assignees')).toBeInTheDocument(); + it('should call the onCreateCasePressed when create case is clicked', async () => { + const onCreateCasePressed = jest.fn(); + appMockRender.render( + + ); + + userEvent.click(screen.getByTestId('cases-table-add-case-filter-bar')); + + await waitForComponentToUpdate(); + // NOTE: intentionally checking no arguments are passed + expect(onCreateCasePressed).toHaveBeenCalledWith(); }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index 5032922a06d12..41c46d5137e98 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -15,6 +15,7 @@ import { StatusAll } from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import type { FilterOptions } from '../../containers/types'; import { FilterPopover } from '../filter_popover'; +import { SolutionFilter } from './solution_filter'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; import { SeverityFilter } from './severity_filter'; @@ -24,6 +25,7 @@ import { AssigneesFilterPopover } from './assignees_filter'; import type { CurrentUserProfile } from '../types'; import { useCasesFeatures } from '../../common/use_cases_features'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; +import type { Solution } from './types'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -32,8 +34,8 @@ interface CasesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; initial: FilterOptions; hiddenStatuses?: CaseStatusWithAllStatus[]; - availableSolutions: string[]; - displayCreateCaseButton?: boolean; + availableSolutions: Solution[]; + isSelectorView?: boolean; onCreateCasePressed?: () => void; isLoading: boolean; currentUserProfile: CurrentUserProfile; @@ -60,7 +62,7 @@ const CasesTableFiltersComponent = ({ initial = DEFAULT_FILTER_OPTIONS, hiddenStatuses, availableSolutions, - displayCreateCaseButton, + isSelectorView = false, onCreateCasePressed, isLoading, currentUserProfile, @@ -156,6 +158,18 @@ const CasesTableFiltersComponent = ({ + {isSelectorView && onCreateCasePressed ? ( + + + {i18n.CREATE_CASE_TITLE} + + + ) : null} - {caseAssignmentAuthorized ? ( + {caseAssignmentAuthorized && !isSelectorView ? ( {availableSolutions.length > 1 && ( - - {displayCreateCaseButton && onCreateCasePressed ? ( - - - {i18n.CREATE_CASE_TITLE} - - - ) : null} ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/types.ts b/x-pack/plugins/cases/public/components/all_cases/types.ts index 5014522177570..7cf9c410ec073 100644 --- a/x-pack/plugins/cases/public/components/all_cases/types.ts +++ b/x-pack/plugins/cases/public/components/all_cases/types.ts @@ -24,3 +24,8 @@ export interface EuiBasicTableOnChange { }; sort?: EuiBasicTableSortTypes; } +export interface Solution { + id: string; + label: string; + iconType: string; +} diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx index f11794bcf13e2..060ac64cafb08 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -97,7 +97,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -148,38 +147,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "assignees", - "name": "Assignees", - "render": [Function], - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -187,24 +155,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -280,7 +230,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -365,7 +314,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -445,7 +393,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -530,7 +477,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", @@ -575,32 +521,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -608,24 +529,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -657,32 +560,7 @@ describe('useCasesColumns ', () => { "name": "Name", "render": [Function], "sortable": true, - "width": undefined, - }, - Object { - "field": "tags", - "name": "Tags", - "render": [Function], - "width": undefined, - }, - Object { - "align": "right", - "field": "totalAlerts", - "name": "Alerts", - "render": [Function], - "width": "55px", - }, - Object { - "align": "right", - "field": "owner", - "name": "Solution", - "render": [Function], - }, - Object { - "align": "right", - "field": "totalComment", - "name": "Comments", - "render": [Function], + "width": "55%", }, Object { "field": "createdAt", @@ -690,24 +568,6 @@ describe('useCasesColumns ', () => { "render": [Function], "sortable": true, }, - Object { - "field": "updatedAt", - "name": "Updated on", - "render": [Function], - "sortable": true, - "width": "80px", - }, - Object { - "name": "External Incident", - "render": [Function], - "width": "80px", - }, - Object { - "field": "status", - "name": "Status", - "render": [Function], - "sortable": true, - }, Object { "field": "severity", "name": "Severity", @@ -776,7 +636,6 @@ describe('useCasesColumns ', () => { "name": "Updated on", "render": [Function], "sortable": true, - "width": undefined, }, Object { "name": "External Incident", diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index a2760c71a8ef5..1f267eca40a84 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -118,7 +118,7 @@ export const useCasesColumns = ({ render: (title: string, theCase: Case) => { if (theCase.id != null && theCase.title != null) { const caseDetailsLinkComponent = isSelectorView ? ( - + theCase.title ) : ( @@ -137,70 +137,72 @@ export const useCasesColumns = ({ } return getEmptyTagValue(); }, - width: !isSelectorView ? '20%' : undefined, + width: !isSelectorView ? '20%' : '55%', }, ]; - if (caseAssignmentAuthorized) { + if (caseAssignmentAuthorized && !isSelectorView) { columns.push({ field: 'assignees', name: i18n.ASSIGNEES, render: (assignees: Case['assignees']) => ( ), - width: !isSelectorView ? '180px' : undefined, + width: '180px', }); } - columns.push({ - field: 'tags', - name: i18n.TAGS, - render: (tags: Case['tags']) => { - if (tags != null && tags.length > 0) { - const clampedBadges = ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); + if (!isSelectorView) { + columns.push({ + field: 'tags', + name: i18n.TAGS, + render: (tags: Case['tags']) => { + if (tags != null && tags.length > 0) { + const clampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); - const unclampedBadges = ( - - {tags.map((tag: string, i: number) => ( - - {tag} - - ))} - - ); + const unclampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); - return ( - - {clampedBadges} - - ); - } - return getEmptyTagValue(); - }, - width: !isSelectorView ? '15%' : undefined, - }); + return ( + + {clampedBadges} + + ); + } + return getEmptyTagValue(); + }, + width: '15%', + }); + } - if (isAlertsEnabled) { + if (isAlertsEnabled && !isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, field: 'totalAlerts', @@ -213,7 +215,7 @@ export const useCasesColumns = ({ }); } - if (showSolutionColumn) { + if (showSolutionColumn && !isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, field: 'owner', @@ -234,15 +236,17 @@ export const useCasesColumns = ({ }); } - columns.push({ - align: RIGHT_ALIGNMENT, - field: 'totalComment', - name: i18n.COMMENTS, - render: (totalComment: Case['totalComment']) => - totalComment != null - ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) - : getEmptyTagValue(), - }); + if (!isSelectorView) { + columns.push({ + align: RIGHT_ALIGNMENT, + field: 'totalComment', + name: i18n.COMMENTS, + render: (totalComment: Case['totalComment']) => + totalComment != null + ? renderStringField(`${totalComment}`, `case-table-column-commentCount`) + : getEmptyTagValue(), + }); + } if (filterStatus === CaseStatuses.closed) { columns.push({ @@ -278,67 +282,70 @@ export const useCasesColumns = ({ }); } + if (!isSelectorView) { + columns.push({ + field: 'updatedAt', + name: i18n.UPDATED_ON, + sortable: true, + render: (updatedAt: Case['updatedAt']) => { + if (updatedAt != null) { + return ( + + + + ); + } + return getEmptyTagValue(); + }, + }); + } + + if (!isSelectorView) { + columns.push( + { + name: i18n.EXTERNAL_INCIDENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ; + } + return getEmptyTagValue(); + }, + width: isSelectorView ? '80px' : undefined, + }, + { + field: 'status', + name: i18n.STATUS, + sortable: true, + render: (status: Case['status']) => { + if (status != null) { + return ; + } + + return getEmptyTagValue(); + }, + } + ); + } columns.push({ - field: 'updatedAt', - name: i18n.UPDATED_ON, + field: 'severity', + name: i18n.SEVERITY, sortable: true, - render: (updatedAt: Case['updatedAt']) => { - if (updatedAt != null) { + render: (severity: Case['severity']) => { + if (severity != null) { + const severityData = severities[severity ?? CaseSeverity.LOW]; return ( - - - + + {severityData.label} + ); } return getEmptyTagValue(); }, - width: isSelectorView ? '80px' : undefined, }); - columns.push( - { - name: i18n.EXTERNAL_INCIDENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ; - } - return getEmptyTagValue(); - }, - width: isSelectorView ? '80px' : undefined, - }, - { - field: 'status', - name: i18n.STATUS, - sortable: true, - render: (status: Case['status']) => { - if (status != null) { - return ; - } - - return getEmptyTagValue(); - }, - }, - { - field: 'severity', - name: i18n.SEVERITY, - sortable: true, - render: (severity: Case['severity']) => { - if (severity != null) { - const severityData = severities[severity ?? CaseSeverity.LOW]; - return ( - - {severityData.label} - - ); - } - return getEmptyTagValue(); - }, - } - ); - if (isSelectorView) { columns.push({ align: RIGHT_ALIGNMENT, @@ -351,7 +358,6 @@ export const useCasesColumns = ({ assignCaseAction(theCase); }} size="s" - fill={true} > {i18n.SELECT} diff --git a/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx new file mode 100644 index 0000000000000..a6c6de8f19770 --- /dev/null +++ b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright 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 { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { FilterPopover } from '.'; +import userEvent from '@testing-library/user-event'; + +describe('FilterPopover ', () => { + let appMockRender: AppMockRenderer; + const onSelectedOptionsChanged = jest.fn(); + const tags: string[] = ['coke', 'pepsi']; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders button label correctly', () => { + const { getByTestId } = appMockRender.render( + + ); + + expect(getByTestId('options-filter-popover-button-Tags')).toBeInTheDocument(); + }); + + it('renders empty label correctly', async () => { + const { getByTestId, getByText } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(getByText('No options available')).toBeInTheDocument(); + }); + + it('renders string type options correctly', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(getByTestId(`options-filter-popover-item-${tags[0]}`)).toBeInTheDocument(); + expect(getByTestId(`options-filter-popover-item-${tags[1]}`)).toBeInTheDocument(); + }); + + it('should call onSelectionChange with selected option', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`options-filter-popover-item-${tags[0]}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([tags[0]]); + }); + + it('should call onSelectionChange with empty array when option is deselected', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`options-filter-popover-item-${tags[0]}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap index 193451e9d146a..4c7c643c80fa9 100644 --- a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap @@ -2268,6 +2268,90 @@ Object { } `; +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionUsers" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_user_action_get_users", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-user-actions", + }, + }, + "message": "Failed attempt to access cases-user-actions [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionUsers" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_user_action_get_users", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a user actions as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionUsers" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_user_action_get_users", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-user-actions", + }, + }, + "message": "User has accessed cases-user-actions [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActionUsers" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_user_action_get_users", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a user actions as any owners", +} +`; + exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" with an error and entity 1`] = ` Object { "error": Object { diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts index cbfef6f4713e3..83529478d1d10 100644 --- a/x-pack/plugins/cases/server/authorization/index.ts +++ b/x-pack/plugins/cases/server/authorization/index.ts @@ -367,4 +367,12 @@ export const Operations: Record>((acc, profile) => { - acc.set(profile.uid, profile); - return acc; - }, new Map()); + return userProfiles; }; diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 06d2f1da63629..6ab60805ad5b1 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -8,6 +8,7 @@ import { uniqBy, isEmpty } from 'lodash'; import type { UserProfile } from '@kbn/security-plugin/common'; import type { IBasePath } from '@kbn/core-http-browser'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { isPushedUserAction } from '../../../common/utils/user_actions'; import type { @@ -430,3 +431,22 @@ export const getDurationForUpdate = ({ }; } }; + +export const getUserProfiles = async ( + securityStartPlugin: SecurityPluginStart, + uids: Set +): Promise> => { + if (uids.size <= 0) { + return new Map(); + } + + const userProfiles = + (await securityStartPlugin.userProfiles.bulkGet({ + uids, + })) ?? []; + + return userProfiles.reduce>((acc, profile) => { + acc.set(profile.uid, profile); + return acc; + }, new Map()); +}; diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 2c17182f49a07..b7e34a4ea2af5 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -95,6 +95,7 @@ const createUserActionsSubClientMock = (): UserActionsSubClientMock => { getAll: jest.fn(), getConnectors: jest.fn(), stats: jest.fn(), + getUsers: jest.fn(), }; }; diff --git a/x-pack/plugins/cases/server/client/user_actions/client.ts b/x-pack/plugins/cases/server/client/user_actions/client.ts index 2676955719adb..eb9581be49cff 100644 --- a/x-pack/plugins/cases/server/client/user_actions/client.ts +++ b/x-pack/plugins/cases/server/client/user_actions/client.ts @@ -10,12 +10,14 @@ import type { CaseUserActionStatsResponse, UserActionFindResponse, CaseUserActionsDeprecatedResponse, + GetCaseUsersResponse, } from '../../../common/api'; import type { CasesClientArgs } from '../types'; import { get } from './get'; import { getConnectors } from './connectors'; import { getStats } from './stats'; -import type { GetConnectorsRequest, UserActionFind, UserActionGet } from './types'; +import { getUsers } from './users'; +import type { GetConnectorsRequest, UserActionFind, UserActionGet, GetUsersRequest } from './types'; import { find } from './find'; import type { CasesClient } from '../client'; @@ -32,11 +34,14 @@ export interface UserActionsSubClient { * Retrieves all the connectors used within a given case */ getConnectors(params: GetConnectorsRequest): Promise; - /** * Retrieves the total of comments and user actions in a given case */ stats(params: UserActionGet): Promise; + /** + * Retrieves all users participating in a case + */ + getUsers(params: GetUsersRequest): Promise; } /** @@ -51,6 +56,7 @@ export const createUserActionsSubClient = ( getAll: (params) => get(params, clientArgs), getConnectors: (params) => getConnectors(params, clientArgs), stats: (params) => getStats(params, casesClient, clientArgs), + getUsers: (params) => getUsers(params, casesClient, clientArgs), }; return Object.freeze(attachmentSubClient); diff --git a/x-pack/plugins/cases/server/client/user_actions/types.ts b/x-pack/plugins/cases/server/client/user_actions/types.ts index 5249bc27d1e58..352ade17d62d8 100644 --- a/x-pack/plugins/cases/server/client/user_actions/types.ts +++ b/x-pack/plugins/cases/server/client/user_actions/types.ts @@ -18,6 +18,7 @@ export interface UserActionGet { } export type GetConnectorsRequest = UserActionGet; +export type GetUsersRequest = UserActionGet; export interface UserActionFind { params: UserActionFindRequest; diff --git a/x-pack/plugins/cases/server/client/user_actions/users.ts b/x-pack/plugins/cases/server/client/user_actions/users.ts new file mode 100644 index 0000000000000..3a1bd7a6bb333 --- /dev/null +++ b/x-pack/plugins/cases/server/client/user_actions/users.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UserProfile } from '@kbn/security-plugin/common'; +import type { GetCaseUsersResponse, User } from '../../../common/api'; +import { GetCaseUsersResponseRt } from '../../../common/api'; +import type { OwnerEntity } from '../../authorization'; +import { Operations } from '../../authorization'; +import { createCaseError } from '../../common/error'; +import type { CasesClient } from '../client'; +import type { CasesClientArgs } from '../types'; +import type { GetUsersRequest } from './types'; +import { getUserProfiles } from '../cases/utils'; + +export const getUsers = async ( + { caseId }: GetUsersRequest, + casesClient: CasesClient, + clientArgs: CasesClientArgs +): Promise => { + const { + services: { userActionService }, + logger, + authorization, + securityStartPlugin, + } = clientArgs; + + try { + // ensure that we have authorization for reading the case + const theCase = await casesClient.cases.resolve({ id: caseId, includeComments: false }); + + const { participants, assignedAndUnassignedUsers } = await userActionService.getUsers({ + caseId, + }); + + const entities: OwnerEntity[] = participants.map((participant) => ({ + id: participant.id, + owner: participant.owner, + })); + + await authorization.ensureAuthorized({ + entities, + operation: Operations.getUserActionUsers, + }); + + const participantsUids = participants + .filter((participant) => participant.user.profile_uid != null) + .map((participant) => participant.user.profile_uid) as string[]; + + const assigneesUids = theCase.case.assignees.map((assignee) => assignee.uid); + + const userProfileUids = new Set([ + ...assignedAndUnassignedUsers, + ...participantsUids, + ...assigneesUids, + ]); + + const userProfiles = await getUserProfiles(securityStartPlugin, userProfileUids); + + const participantsResponse = convertUserInfoToResponse( + userProfiles, + participants.map((participant) => ({ + uid: participant.user.profile_uid, + user: participant.user, + })) + ); + + const assigneesResponse = convertUserInfoToResponse(userProfiles, theCase.case.assignees); + + /** + * To avoid duplicates, a user that is + * a participant or an assignee should not be + * part of the assignedAndUnassignedUsers Set + */ + const unassignedUsers = removeAllFromSet(assignedAndUnassignedUsers, [ + ...participantsUids, + ...assigneesUids, + ]); + + const unassignedUsersResponse = convertUserInfoToResponse( + userProfiles, + [...unassignedUsers.values()].map((uid) => ({ + uid, + })) + ); + + const results = { + participants: participantsResponse, + assignees: assigneesResponse, + unassignedUsers: unassignedUsersResponse, + }; + + return GetCaseUsersResponseRt.encode(results); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve the case users case id: ${caseId}: ${error}`, + error, + logger, + }); + } +}; + +const convertUserInfoToResponse = ( + userProfiles: Map, + usersInfo: Array<{ uid: string | undefined; user?: User }> +): User[] => { + const response = []; + + for (const info of usersInfo) { + response.push(getUserInformation(userProfiles, info.uid, info.user)); + } + + return response; +}; + +const getUserInformation = ( + userProfiles: Map, + uid: string | undefined, + userInfo?: User +): User => { + const userProfile = uid != null ? userProfiles.get(uid) : undefined; + + return { + email: userProfile?.user.email ?? userInfo?.email, + full_name: userProfile?.user.full_name ?? userInfo?.full_name, + username: userProfile?.user.username ?? userInfo?.username, + profile_uid: userProfile?.uid ?? uid ?? userInfo?.profile_uid, + }; +}; + +const removeAllFromSet = (originalSet: Set, values: string[]) => { + const newSet = new Set(originalSet); + values.forEach((value) => newSet.delete(value)); + + return newSet; +}; diff --git a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts index 41d9904de01bd..fd7e08f222c9a 100644 --- a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts +++ b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts @@ -13,6 +13,7 @@ import { bulkGetCasesRoute } from './internal/bulk_get_cases'; import { suggestUserProfilesRoute } from './internal/suggest_user_profiles'; import type { CaseRoute } from './types'; import { bulkGetAttachmentsRoute } from './internal/bulk_get_attachments'; +import { getCaseUsersRoute } from './internal/get_case_users'; export const getInternalRoutes = (userProfileService: UserProfileService) => [ @@ -22,4 +23,5 @@ export const getInternalRoutes = (userProfileService: UserProfileService) => bulkGetCasesRoute, getCaseUserActionStatsRoute, bulkGetAttachmentsRoute, + getCaseUsersRoute, ] as CaseRoute[]; diff --git a/x-pack/plugins/cases/server/routes/api/internal/get_case_users.ts b/x-pack/plugins/cases/server/routes/api/internal/get_case_users.ts new file mode 100644 index 0000000000000..56b031e548a54 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/internal/get_case_users.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { INTERNAL_CASE_USERS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; + +export const getCaseUsersRoute = createCasesRoute({ + method: 'get', + path: INTERNAL_CASE_USERS_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + handler: async ({ context, request, response }) => { + try { + const casesContext = await context.cases; + const casesClient = await casesContext.getCasesClient(); + const caseId = request.params.case_id; + + return response.ok({ + body: await casesClient.userActions.getUsers({ caseId }), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve users in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts index 3060ca219ca42..067ecaaf8fe1e 100644 --- a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts @@ -60,6 +60,12 @@ export const createCaseUserActionSavedObjectType = ( persistableStateAttachmentTypeId: { type: 'keyword' }, }, }, + assignees: { + properties: { + // assignees.uid + uid: { type: 'keyword' }, + }, + }, }, }, owner: { diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index ee65ce172c596..eb2bfd0fc8c47 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -125,6 +125,7 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => { getUniqueConnectors: jest.fn(), getUserActionIdsForCases: jest.fn(), getCaseUserActionStats: jest.fn(), + getUsers: jest.fn(), }; // the cast here is required because jest.Mocked tries to include private members and would throw an error diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 159cb40a5f1f8..252e3985a7483 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -18,6 +18,7 @@ import type { CaseUserActionAttributesWithoutConnectorId, CaseUserActionDeprecatedResponse, CaseUserActionInjectedAttributes, + User, } from '../../../common/api'; import { ActionTypes } from '../../../common/api'; import { @@ -98,6 +99,29 @@ interface UserActionsStatsAggsResult { }; } +interface ParticipantsAggsResult { + participants: { + buckets: Array<{ + key: string; + docs: { + hits: { + hits: SavedObjectsRawDoc[]; + }; + }; + }>; + }; + assignees: { + buckets: Array<{ + key: string; + }>; + }; +} + +interface GetUsersResponse { + participants: Array<{ id: string; owner: string; user: User }>; + assignedAndUnassignedUsers: Set; +} + export class CaseUserActionService { private readonly _creator: UserActionPersister; private readonly _finder: UserActionFinder; @@ -715,4 +739,90 @@ export class CaseUserActionService { }, }; } + + public async getUsers({ caseId }: { caseId: string }): Promise { + const response = await this.context.unsecuredSavedObjectsClient.find< + CaseUserActionAttributesWithoutConnectorId, + ParticipantsAggsResult + >({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: CaseUserActionService.buildParticipantsAgg(), + }); + + const assignedAndUnassignedUsers: GetUsersResponse['assignedAndUnassignedUsers'] = + new Set(); + const participants: GetUsersResponse['participants'] = []; + const participantsBuckets = response.aggregations?.participants.buckets ?? []; + const assigneesBuckets = response.aggregations?.assignees.buckets ?? []; + + for (const bucket of participantsBuckets) { + const rawDoc = bucket.docs.hits.hits[0]; + const user = + this.context.savedObjectsSerializer.rawToSavedObject( + rawDoc + ); + + /** + * We are interested only for the created_by + * and the owner. For that reason, there is no + * need to call transformToExternalModel which + * injects the references ids to the document. + */ + participants.push({ + id: user.id, + user: user.attributes.created_by, + owner: user.attributes.owner, + }); + } + + /** + * The users set includes any + * user that got assigned in the + * case even if they removed as + * assignee at some point in time. + */ + for (const bucket of assigneesBuckets) { + assignedAndUnassignedUsers.add(bucket.key); + } + + return { participants, assignedAndUnassignedUsers }; + } + + private static buildParticipantsAgg(): Record { + return { + participants: { + terms: { + field: `${CASE_USER_ACTION_SAVED_OBJECT}.attributes.created_by.username`, + size: MAX_DOCS_PER_PAGE, + order: { _key: 'asc' }, + missing: 'Unknown', + }, + aggregations: { + docs: { + top_hits: { + size: 1, + sort: [ + { + [`${CASE_USER_ACTION_SAVED_OBJECT}.created_at`]: { + order: 'desc', + }, + }, + ], + }, + }, + }, + }, + assignees: { + terms: { + field: `${CASE_USER_ACTION_SAVED_OBJECT}.attributes.payload.assignees.uid`, + size: MAX_DOCS_PER_PAGE, + order: { _key: 'asc' }, + }, + }, + }; + } } diff --git a/x-pack/plugins/cloud_integrations/cloud_data_migration/public/application/components/app.tsx b/x-pack/plugins/cloud_integrations/cloud_data_migration/public/application/components/app.tsx index d48cb672e05c3..2823a44c09a23 100755 --- a/x-pack/plugins/cloud_integrations/cloud_data_migration/public/application/components/app.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_data_migration/public/application/components/app.tsx @@ -72,7 +72,7 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr

@@ -86,7 +86,7 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr } @@ -97,8 +97,8 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr label={ } @@ -109,8 +109,8 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr label={ } @@ -121,8 +121,8 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr label={ } @@ -141,7 +141,7 @@ export const CloudDataMigrationApp = ({ http, breadcrumbService }: CloudDataMigr >
diff --git a/x-pack/plugins/cloud_security_posture/README.md b/x-pack/plugins/cloud_security_posture/README.md index 7a4646d08ba07..a655d292c39ee 100755 --- a/x-pack/plugins/cloud_security_posture/README.md +++ b/x-pack/plugins/cloud_security_posture/README.md @@ -49,3 +49,8 @@ yarn test:ftr --config x-pack/test/cloud_security_posture_functional/config.ts > **Note** > in development, run them separately with `ftr:runner` and `ftr:server` + +```bash +yarn test:ftr:server --config x-pack/test/api_integration/config.ts +yarn test:ftr:runner --include-tag=cloud_security_posture --config x-pack/test/api_integration/config.ts +``` \ No newline at end of file diff --git a/x-pack/plugins/data_visualizer/kibana.json b/x-pack/plugins/data_visualizer/kibana.json index 98d27ba9f0481..e7a136c1d5c83 100644 --- a/x-pack/plugins/data_visualizer/kibana.json +++ b/x-pack/plugins/data_visualizer/kibana.json @@ -32,7 +32,8 @@ "fieldFormats", "uiActions", "lens", - "cloudChat" + "cloudChat", + "savedSearch" ], "owner": { "name": "Machine Learning UI", diff --git a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts index fb782a02a08d4..5afa3869abd88 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { DataViewField } from '@kbn/data-views-plugin/public'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types'; -import { SupportedFieldType } from '../../../../common/types'; +import type { SupportedFieldType } from '../../../../common/types'; import { SUPPORTED_FIELD_TYPES } from '../../../../common/constants'; export const getJobTypeLabel = (type: string) => { diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index 9f969fac23a38..ea72215a89598 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -36,6 +36,7 @@ import { } from '@kbn/ml-date-picker'; import { useStorage } from '@kbn/ml-local-storage'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import { useCurrentEuiTheme } from '../../../common/hooks/use_current_eui_theme'; import { DV_FROZEN_TIER_PREFERENCE, @@ -57,7 +58,7 @@ import { DataVisualizerIndexBasedPageUrlState, } from '../../types/index_data_visualizer_state'; import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../../types/combined_query'; -import { SupportedFieldType, SavedSearchSavedObject } from '../../../../../common/types'; +import type { SupportedFieldType } from '../../../../../common/types'; import { useDataVisualizerKibana } from '../../../kibana_context'; import { FieldCountPanel } from '../../../common/components/field_count_panel'; import { DocumentCountContent } from '../../../common/components/document_count_content'; @@ -129,7 +130,7 @@ export const getDefaultDataVisualizerListState = ( export interface IndexDataVisualizerViewProps { currentDataView: DataView; - currentSavedSearch: SavedSearchSavedObject | null; + currentSavedSearch: SavedSearch | null; currentSessionId?: string; getAdditionalLinks?: GetAdditionalLinks; } @@ -178,12 +179,6 @@ export const IndexDataVisualizerView: FC = (dataVi const { currentDataView, currentSessionId, getAdditionalLinks } = dataVisualizerProps; - useEffect(() => { - if (dataVisualizerProps?.currentSavedSearch !== undefined) { - setCurrentSavedSearch(dataVisualizerProps?.currentSavedSearch); - } - }, [dataVisualizerProps?.currentSavedSearch]); - useEffect(() => { if (!currentDataView.isTimeBased()) { toasts.addWarning({ diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx index ac93003d1817e..9aeab958683e7 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/search_panel.tsx @@ -21,7 +21,7 @@ import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { isDefined } from '@kbn/ml-is-defined'; import { DataVisualizerFieldNamesFilter } from './field_name_filter'; import { DataVisualizerFieldTypeFilter } from './field_type_filter'; -import { SupportedFieldType } from '../../../../../common/types'; +import type { SupportedFieldType } from '../../../../../common/types'; import { SearchQueryLanguage } from '../../types/combined_query'; import { useDataVisualizerKibana } from '../../../kibana_context'; import { createMergedEsQuery } from '../../utils/saved_search_utils'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx index 6534ace2343d8..d9cd0dfbaccfd 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx @@ -27,8 +27,8 @@ import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-pl import type { Query } from '@kbn/es-query'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { DatePickerContextProvider } from '@kbn/ml-date-picker'; -import { SavedSearch } from '@kbn/discover-plugin/public'; -import { SamplingOption } from '../../../../../common/types/field_stats'; +import type { SavedSearch } from '@kbn/discover-plugin/public'; +import type { SamplingOption } from '../../../../../common/types/field_stats'; import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants'; import { EmbeddableLoading } from './embeddable_loading_fallback'; import { DataVisualizerStartDependencies } from '../../../../plugin'; @@ -38,7 +38,7 @@ import { } from '../../../common/components/stats_table'; import { FieldVisConfig } from '../../../common/components/stats_table/types'; import { getDefaultDataVisualizerListState } from '../../components/index_data_visualizer_view/index_data_visualizer_view'; -import type { DataVisualizerTableState, SavedSearchSavedObject } from '../../../../../common/types'; +import type { DataVisualizerTableState } from '../../../../../common/types'; import type { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state'; import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row'; import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data'; @@ -46,7 +46,7 @@ import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_ export type DataVisualizerGridEmbeddableServices = [CoreStart, DataVisualizerStartDependencies]; export interface DataVisualizerGridInput { dataView: DataView; - savedSearch?: SavedSearch | SavedSearchSavedObject | null; + savedSearch?: SavedSearch | null; query?: Query; visibleFieldNames?: string[]; filters?: Filter[]; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts index 858df7c6366b7..66902ed0fe190 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_data_visualizer_grid_data.ts @@ -98,7 +98,7 @@ export const useDataVisualizerGridData = ( ); /** Prepare required params to pass to search strategy **/ - const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { + const { searchQueryLanguage, searchString, searchQuery, queryOrAggregateQuery } = useMemo(() => { const filterManager = data.query.filterManager; const searchData = getEsQueryFromSavedSearch({ dataView: currentDataView, @@ -123,6 +123,7 @@ export const useDataVisualizerGridData = ( }; } else { return { + queryOrAggregateQuery: searchData.queryOrAggregateQuery, searchQuery: searchData.searchQuery, searchString: searchData.searchString, searchQueryLanguage: searchData.queryLanguage, @@ -563,6 +564,7 @@ export const useDataVisualizerGridData = ( progress: combinedProgress, overallStatsProgress, configs, + queryOrAggregateQuery, searchQueryLanguage, searchString, searchQuery, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index f7c70625f0e65..7633d63286f8e 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -11,7 +11,6 @@ import { useHistory, useLocation } from 'react-router-dom'; import { parse, stringify } from 'query-string'; import { isEqual } from 'lodash'; import { encode } from '@kbn/rison'; -import { SimpleSavedObject } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { @@ -33,14 +32,15 @@ import { type Dictionary, type SetUrlState, } from '@kbn/ml-url-state'; +import { getSavedSearch, type SavedSearch } from '@kbn/saved-search-plugin/public'; import { getCoreStart, getPluginsStart } from '../../kibana_services'; import { - IndexDataVisualizerViewProps, + type IndexDataVisualizerViewProps, IndexDataVisualizerView, } from './components/index_data_visualizer_view'; import { useDataVisualizerKibana } from '../kibana_context'; -import { GetAdditionalLinks } from '../common/components/results_links'; -import { DATA_VISUALIZER_APP_LOCATOR, IndexDataVisualizerLocatorParams } from './locator'; +import type { GetAdditionalLinks } from '../common/components/results_links'; +import { DATA_VISUALIZER_APP_LOCATOR, type IndexDataVisualizerLocatorParams } from './locator'; import { DATA_VISUALIZER_INDEX_VIEWER } from './constants/index_data_visualizer_viewer'; import { INDEX_DATA_VISUALIZER_NAME } from '../common/constants'; import { DV_STORAGE_KEYS } from './types/storage'; @@ -100,9 +100,7 @@ export const DataVisualizerStateContextProvider: FC(undefined); - const [currentSavedSearch, setCurrentSavedSearch] = useState | null>( - null - ); + const [currentSavedSearch, setCurrentSavedSearch] = useState(null); const [currentSessionId, setCurrentSessionId] = useState(undefined); @@ -161,21 +159,21 @@ export const DataVisualizerStateContextProvider: FC ref.type === 'index-pattern')?.id; - if (dataViewId !== undefined && savedSearch) { - try { - const dataView = await dataViews.get(dataViewId); - setCurrentSavedSearch(savedSearch); - setCurrentDataView(dataView); - } catch (e) { - toasts.addError(e, { - title: i18n.translate('xpack.dataVisualizer.index.dataViewErrorMessage', { - defaultMessage: 'Error finding data view', - }), - }); - } + const savedSearch = await getSavedSearch(savedSearchId, { + search, + savedObjectsClient, + }); + const dataView = savedSearch.searchSource.getField('index'); + + if (!dataView) { + toasts.addDanger({ + title: i18n.translate('xpack.dataVisualizer.index.dataViewErrorMessage', { + defaultMessage: 'Error finding data view', + }), + }); } + setCurrentSavedSearch(savedSearch); + setCurrentDataView(dataView); } catch (e) { toasts.addError(e, { title: i18n.translate('xpack.dataVisualizer.index.savedSearchErrorMessage', { @@ -192,7 +190,7 @@ export const DataVisualizerStateContextProvider: FC 49', language: 'lucene' } as Query, + filter: [ + { + meta: { + alias: null, + disabled: false, + negate: false, + params: [ + { + meta: { + alias: null, + disabled: false, + field: 'airline', + index: 'cb8808e0-9bfb-11ed-bb38-2b1bd55401e7', + key: 'airline', + negate: false, + params: { + query: 'ACA', + }, + type: 'phrase', + }, + query: { + match_phrase: { + airline: 'ACA', + }, + }, + }, + { + meta: { + alias: null, + disabled: false, + field: 'airline', + index: 'cb8808e0-9bfb-11ed-bb38-2b1bd55401e7', + key: 'airline', + negate: false, + params: { + query: 'FFT', + }, + type: 'phrase', + }, + query: { + match_phrase: { + airline: 'FFT', + }, + }, + }, + ] as FilterMetaParams, + // @ts-expect-error SavedSearch needs to be updated with CombinedFilterMeta + relation: 'OR', + type: 'combined', + index: 'cb8808e0-9bfb-11ed-bb38-2b1bd55401e7', + }, + query: {}, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + ], + }), +} as unknown as SavedSearch; + // @ts-expect-error We don't need the full object here const luceneSavedSearchObj: SavedSearchSavedObject = { attributes: { @@ -75,12 +145,27 @@ const kqlSavedSearch: SavedSearch = { title: 'farequote_filter_and_kuery', description: '', columns: ['_source'], - // @ts-expect-error this isn't a valid SavedSearch object... but does anyone care? - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"highlightAll":true,"version":true,"query":{"query":"responsetime > 49","language":"kuery"},"filter":[{"meta":{"index":"90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0","negate":false,"disabled":false,"alias":null,"type":"phrase","key":"airline","value":"ASA","params":{"query":"ASA","type":"phrase"}},"query":{"match":{"airline":{"query":"ASA","type":"phrase"}}},"$state":{"store":"appState"}}],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', - }, -}; + searchSource: createSearchSourceMock({ + index: mockDataView, + query: { query: 'responsetime > 49', language: 'kuery' } as Query, + filter: [ + { + meta: { + index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', + negate: false, + disabled: false, + alias: null, + type: 'phrase', + key: 'airline', + value: 'ASA', + params: { query: 'ASA', type: 'phrase' }, + }, + query: { match: { airline: { query: 'ASA', type: 'phrase' } } }, + $state: { store: FilterStateStore.APP_STATE }, + }, + ], + }), +} as unknown as SavedSearch; describe('getQueryFromSavedSearchObject()', () => { it('should return parsed searchSourceJSON with query and filter', () => { @@ -107,26 +192,24 @@ describe('getQueryFromSavedSearchObject()', () => { version: true, }); expect(getQueryFromSavedSearchObject(kqlSavedSearch)).toEqual({ + query: { query: 'responsetime > 49', language: 'kuery' }, + index: 'test-mock-data-view', filter: [ { - $state: { store: 'appState' }, meta: { - alias: null, - disabled: false, index: '90a978e0-1c80-11ec-b1d7-f7e5cf21b9e0', - key: 'airline', negate: false, - params: { query: 'ASA', type: 'phrase' }, + disabled: false, + alias: null, type: 'phrase', + key: 'airline', value: 'ASA', + params: { query: 'ASA', type: 'phrase' }, }, query: { match: { airline: { query: 'ASA', type: 'phrase' } } }, + $state: { store: 'appState' }, }, ], - highlightAll: true, - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - query: { language: 'kuery', query: 'responsetime > 49' }, - version: true, }); }); it('should return undefined if invalid searchSourceJSON', () => { @@ -222,27 +305,55 @@ describe('getEsQueryFromSavedSearch()', () => { expect( getEsQueryFromSavedSearch({ dataView: mockDataView, - savedSearch: luceneSavedSearchObj, + savedSearch: luceneSavedSearch, uiSettings: mockUiSettings, }) ).toEqual({ queryLanguage: 'lucene', + queryOrAggregateQuery: { + language: 'lucene', + query: 'responsetime > 49', + }, searchQuery: { bool: { - filter: [{ match_phrase: { airline: { query: 'ASA' } } }], - must: [{ query_string: { query: 'responsetime:>50' } }], + filter: [ + { + bool: { + should: [ + { + bool: { + must: [], + filter: [{ match_phrase: { airline: 'ACA' } }], + should: [], + must_not: [], + }, + }, + { + bool: { + must: [], + filter: [{ match_phrase: { airline: 'FFT' } }], + should: [], + must_not: [], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + must: [{ query_string: { query: 'responsetime > 49' } }], must_not: [], should: [], }, }, - searchString: 'responsetime:>50', + searchString: 'responsetime > 49', }); }); it('should override original saved search with the provided query ', () => { expect( getEsQueryFromSavedSearch({ dataView: mockDataView, - savedSearch: luceneSavedSearchObj, + savedSearch: luceneSavedSearch, uiSettings: mockUiSettings, query: { query: 'responsetime:>100', @@ -251,9 +362,34 @@ describe('getEsQueryFromSavedSearch()', () => { }) ).toEqual({ queryLanguage: 'lucene', + queryOrAggregateQuery: { language: 'lucene', query: 'responsetime:>100' }, searchQuery: { bool: { - filter: [{ match_phrase: { airline: { query: 'ASA' } } }], + filter: [ + { + bool: { + should: [ + { + bool: { + must: [], + filter: [{ match_phrase: { airline: 'ACA' } }], + should: [], + must_not: [], + }, + }, + { + bool: { + must: [], + filter: [{ match_phrase: { airline: 'FFT' } }], + should: [], + must_not: [], + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], must: [{ query_string: { query: 'responsetime:>100' } }], must_not: [], should: [], @@ -267,7 +403,7 @@ describe('getEsQueryFromSavedSearch()', () => { expect( getEsQueryFromSavedSearch({ dataView: mockDataView, - savedSearch: luceneSavedSearchObj, + savedSearch: luceneSavedSearch, uiSettings: mockUiSettings, query: { query: 'responsetime:>100', @@ -299,6 +435,7 @@ describe('getEsQueryFromSavedSearch()', () => { }) ).toEqual({ queryLanguage: 'lucene', + queryOrAggregateQuery: { language: 'lucene', query: 'responsetime:>100' }, searchQuery: { bool: { filter: [], diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts index a4678c5ec5dec..c3473c170b370 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts @@ -16,13 +16,13 @@ import { buildEsQuery, Query, Filter, + AggregateQuery, } from '@kbn/es-query'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { SearchSource } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/public'; -import { SavedSearch } from '@kbn/discover-plugin/public'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; -import { FilterManager } from '@kbn/data-plugin/public'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { getEsQueryConfig, isQuery, SearchSource } from '@kbn/data-plugin/common'; +import { FilterManager, mapAndFlattenFilters } from '@kbn/data-plugin/public'; import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../types/combined_query'; import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types'; @@ -45,10 +45,12 @@ export function getDefaultQuery() { * from a saved search or saved search object */ export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObject | SavedSearch) { - const search = isSavedSearchSavedObject(savedSearch) - ? savedSearch?.attributes?.kibanaSavedObjectMeta - : // @ts-ignore - savedSearch?.kibanaSavedObjectMeta; + if (!isSavedSearchSavedObject(savedSearch)) { + return savedSearch.searchSource.getSerializedFields(); + } + const search = + savedSearch?.attributes?.kibanaSavedObjectMeta ?? // @ts-ignore + savedSearch?.kibanaSavedObjectMeta; const parsed = typeof search?.searchSourceJSON === 'string' @@ -75,14 +77,14 @@ export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObjec * Should also form a valid query if only the query or filters is provided */ export function createMergedEsQuery( - query?: Query, + query?: Query | AggregateQuery | undefined, filters?: Filter[], dataView?: DataView, uiSettings?: IUiSettingsClient ) { let combinedQuery: QueryDslQueryContainer = getDefaultQuery(); - if (query && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { + if (isQuery(query) && query.language === SEARCH_QUERY_LANGUAGE.KUERY) { const ast = fromKueryExpression(query.query); if (query.query !== '') { combinedQuery = toElasticsearchQuery(ast, dataView); @@ -114,6 +116,14 @@ export function createMergedEsQuery( return combinedQuery; } +function getSavedSearchSource(savedSearch: SavedSearch) { + return savedSearch && + 'searchSource' in savedSearch && + savedSearch?.searchSource instanceof SearchSource + ? savedSearch.searchSource + : undefined; +} + /** * Extract query data from the saved search object * with overrides from the provided query data and/or filters @@ -128,7 +138,7 @@ export function getEsQueryFromSavedSearch({ }: { dataView: DataView; uiSettings: IUiSettingsClient; - savedSearch: SavedSearchSavedObject | SavedSearch | null | undefined; + savedSearch: SavedSearch | null | undefined; query?: Query; filters?: Filter[]; filterManager?: FilterManager; @@ -138,17 +148,13 @@ export function getEsQueryFromSavedSearch({ const userQuery = query; const userFilters = filters; + const savedSearchSource = getSavedSearchSource(savedSearch); + // If saved search has a search source with nested parent // e.g. a search coming from Dashboard saved search embeddable // which already combines both the saved search's original query/filters and the Dashboard's // then no need to process any further - if ( - savedSearch && - 'searchSource' in savedSearch && - savedSearch?.searchSource instanceof SearchSource && - savedSearch.searchSource.getParent() !== undefined && - userQuery - ) { + if (savedSearchSource && savedSearchSource.getParent() !== undefined && userQuery) { // Flattened query from search source may contain a clause that narrows the time range // which might interfere with global time pickers so we need to remove const savedQuery = @@ -168,13 +174,9 @@ export function getEsQueryFromSavedSearch({ }; } - // If saved search is an json object with the original query and filter - // retrieve the parsed query and filter - const savedSearchData = getQueryFromSavedSearchObject(savedSearch); - // If no saved search available, use user's query and filters - if (!savedSearchData && userQuery) { - if (filterManager && userFilters) filterManager.addFilters(userFilters, false); + if (!savedSearch && userQuery) { + if (filterManager && userFilters) filterManager.addFilters(userFilters); const combinedQuery = createMergedEsQuery( userQuery, @@ -190,13 +192,14 @@ export function getEsQueryFromSavedSearch({ }; } - // If saved search available, merge saved search with latest user query or filters + // If saved search available, merge saved search with the latest user query or filters // which might differ from extracted saved search data - if (savedSearchData) { + if (savedSearchSource) { const globalFilters = filterManager?.getGlobalFilters(); - const currentQuery = userQuery ?? savedSearchData?.query; - const currentFilters = userFilters ?? savedSearchData?.filter; - + // FIXME: Add support for AggregateQuery type #150091 + const currentQuery = userQuery ?? (savedSearchSource.getField('query') as Query); + const currentFilters = + userFilters ?? mapAndFlattenFilters(savedSearchSource.getField('filter') as Filter[]); if (filterManager) filterManager.setFilters(currentFilters); if (globalFilters) filterManager?.addFilters(globalFilters); @@ -209,8 +212,9 @@ export function getEsQueryFromSavedSearch({ return { searchQuery: combinedQuery, - searchString: currentQuery.query, - queryLanguage: currentQuery.language as SearchQueryLanguage, + searchString: currentQuery?.query ?? '', + queryLanguage: (currentQuery?.language as SearchQueryLanguage) ?? 'kuery', + queryOrAggregateQuery: currentQuery, }; } } diff --git a/x-pack/plugins/data_visualizer/tsconfig.json b/x-pack/plugins/data_visualizer/tsconfig.json index fd61172e31587..7472c3910f7fe 100644 --- a/x-pack/plugins/data_visualizer/tsconfig.json +++ b/x-pack/plugins/data_visualizer/tsconfig.json @@ -56,6 +56,7 @@ "@kbn/ml-date-picker", "@kbn/ml-is-defined", "@kbn/ml-query-utils", + "@kbn/saved-search-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/enterprise_search/common/types/connectors.ts b/x-pack/plugins/enterprise_search/common/types/connectors.ts index 2913423b46285..78f3f9e6a51e4 100644 --- a/x-pack/plugins/enterprise_search/common/types/connectors.ts +++ b/x-pack/plugins/enterprise_search/common/types/connectors.ts @@ -10,7 +10,9 @@ export interface KeyValuePair { value: string | null; } -export type ConnectorConfiguration = Record; +export type ConnectorConfiguration = Record & { + extract_full_html?: { label: string; value: boolean }; +}; export interface ConnectorScheduling { enabled: boolean; @@ -27,10 +29,6 @@ export interface CustomScheduling { export type ConnectorCustomScheduling = Record; -export interface ConnectorPreferences extends Record { - extract_full_html?: boolean | null; -} - export enum ConnectorStatus { CREATED = 'created', NEEDS_CONFIGURATION = 'needs_configuration', @@ -154,7 +152,6 @@ export interface Connector { last_synced: string | null; name: string; pipeline?: IngestPipelineParams | null; - preferences: ConnectorPreferences; scheduling: { enabled: boolean; interval: string; // crontab syntax diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts index c9ccc1c0e7e65..955cf219a8019 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/search_indices.mock.ts @@ -105,7 +105,6 @@ export const indices: ElasticsearchIndexWithIngestion[] = [ last_sync_status: SyncStatus.COMPLETED, last_synced: null, name: 'connector', - preferences: { extract_full_html: false }, scheduling: { enabled: false, interval: '', @@ -201,7 +200,6 @@ export const indices: ElasticsearchIndexWithIngestion[] = [ last_sync_status: SyncStatus.COMPLETED, last_synced: null, name: 'crawler', - preferences: { extract_full_html: false }, scheduling: { enabled: false, interval: '', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts index 05211e48bf134..78ef10664abcb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/__mocks__/view_index.mock.ts @@ -115,7 +115,6 @@ export const connectorIndex: ConnectorViewIndex = { last_sync_status: SyncStatus.COMPLETED, last_synced: null, name: 'connector', - preferences: { extract_full_html: false }, scheduling: { enabled: false, interval: '', @@ -215,7 +214,6 @@ export const crawlerIndex: CrawlerViewIndex = { last_sync_status: SyncStatus.COMPLETED, last_synced: null, name: 'crawler', - preferences: { extract_full_html: false }, scheduling: { enabled: false, interval: '', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.test.ts new file mode 100644 index 0000000000000..163a29e07f42e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockHttpValues } from '../../../__mocks__/kea_logic'; + +import { updateHtmlExtraction } from './update_html_extraction_api_logic'; + +describe('UpdateHtmlExtractionApiLogic', () => { + const { http } = mockHttpValues; + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('updateHtmlExtraction', () => { + it('calls correct api', async () => { + const indexName = 'elastic-co-crawler'; + + http.get.mockReturnValue(Promise.resolve()); + + const result = updateHtmlExtraction({ htmlExtraction: true, indexName }); + + expect(http.put).toHaveBeenCalledWith( + `/internal/enterprise_search/indices/${indexName}/crawler/html_extraction`, + { + body: JSON.stringify({ + extract_full_html: true, + }), + } + ); + await expect(result).resolves.toEqual({ htmlExtraction: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.ts new file mode 100644 index 0000000000000..e6ddcc5f82071 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/crawler/update_html_extraction_api_logic.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Actions } from '../../../shared/api_logic/create_api_logic'; + +import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { HttpLogic } from '../../../shared/http'; + +export interface UpdateHtmlExtractionArgs { + htmlExtraction: boolean; + indexName: string; +} + +export interface UpdateHtmlExtractionResponse { + htmlExtraction: boolean; +} + +export const updateHtmlExtraction = async ({ + htmlExtraction, + indexName, +}: UpdateHtmlExtractionArgs) => { + const route = `/internal/enterprise_search/indices/${indexName}/crawler/html_extraction`; + + const params = { extract_full_html: htmlExtraction }; + + await HttpLogic.values.http.put(route, { + body: JSON.stringify(params), + }); + return { htmlExtraction }; +}; + +export const UpdateHtmlExtractionApiLogic = createApiLogic( + ['update_html_extraction_api_logic'], + updateHtmlExtraction +); + +export type UpdateHtmlExtractionActions = Actions< + UpdateHtmlExtractionArgs, + UpdateHtmlExtractionResponse +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx index 852e3698b968c..22ab72ba3a405 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/add_indices_flyout.tsx @@ -99,7 +99,12 @@ export const AddIndicesFlyout: React.FC = ({ onClose }) = - + {i18n.translate( 'xpack.enterpriseSearch.content.engine.indices.addIndicesFlyout.submitButton', { defaultMessage: 'Add selected' } @@ -107,7 +112,11 @@ export const AddIndicesFlyout: React.FC = ({ onClose }) = - + {i18n.translate( 'xpack.enterpriseSearch.content.engine.indices.addIndicesFlyout.cancelButton', { defaultMessage: 'Cancel' } diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx index 0bcdde5e47647..da664b3d97490 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_indices.tsx @@ -28,6 +28,7 @@ import { indexHealthToHealthColor } from '../../../shared/constants/health_color import { generateEncodedPath } from '../../../shared/encode_path_params'; import { KibanaLogic } from '../../../shared/kibana'; import { EuiLinkTo } from '../../../shared/react_router_helpers'; +import { TelemetryLogic } from '../../../shared/telemetry/telemetry_logic'; import { SEARCH_INDEX_PATH, EngineViewTabs } from '../../routes'; import { IngestionMethod } from '../../types'; @@ -40,6 +41,7 @@ import { EngineIndicesLogic } from './engine_indices_logic'; import { EngineViewHeaderActions } from './engine_view_header_actions'; export const EngineIndices: React.FC = () => { + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); const { engineData, engineName, isLoadingEngine, addIndicesFlyoutOpen } = useValues(EngineIndicesLogic); const { removeIndexFromEngine, openAddIndicesFlyout, closeAddIndicesFlyout } = @@ -157,7 +159,13 @@ export const EngineIndices: React.FC = () => { }, } ), - onClick: (index) => setConfirmRemoveIndex(index.name), + onClick: (index) => { + setConfirmRemoveIndex(index.name); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-indices-removeIndex', + }); + }, type: 'icon', }, ], @@ -180,6 +188,7 @@ export const EngineIndices: React.FC = () => { { onConfirm={() => { removeIndexFromEngine(removeIndexConfirm); setConfirmRemoveIndex(null); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-indices-removeIndexConfirm', + }); }} title={i18n.translate( 'xpack.enterpriseSearch.content.engine.indices.removeIndexConfirm.title', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx index ba563a9c23dbc..bd48ee8d6d294 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_view_header_actions.tsx @@ -13,12 +13,15 @@ import { EuiPopover, EuiButtonIcon, EuiText, EuiContextMenu, EuiIcon } from '@el import { i18n } from '@kbn/i18n'; +import { TelemetryLogic } from '../../../shared/telemetry/telemetry_logic'; + import { EngineViewLogic } from './engine_view_logic'; export const EngineViewHeaderActions: React.FC = () => { const { engineData } = useValues(EngineViewLogic); const { openDeleteEngineModal } = useActions(EngineViewLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); const toggleActionsPopover = () => setIsActionsPopoverOpen((isPopoverOpen) => !isPopoverOpen); @@ -66,6 +69,10 @@ export const EngineViewHeaderActions: React.FC = () => { onClick: () => { if (engineData) { openDeleteEngineModal(); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-engineView-deleteEngine', + }); } }, size: 's', diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx index 93544cc2e9bbd..e20fbc81689a4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/header_docs_action.tsx @@ -15,8 +15,9 @@ export const EngineHeaderDocsAction: React.FC = () => ( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx index b3c5c46b38128..017564343f11e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/components/tables/engines_table.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useValues } from 'kea'; +import { useValues, useActions } from 'kea'; import { CriteriaWithPagination, @@ -29,6 +29,7 @@ import { FormattedDateTime } from '../../../../../shared/formatted_date_time'; import { KibanaLogic } from '../../../../../shared/kibana'; import { pageToPagination } from '../../../../../shared/pagination/page_to_pagination'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { TelemetryLogic } from '../../../../../shared/telemetry/telemetry_logic'; import { ENGINE_PATH } from '../../../../routes'; @@ -50,6 +51,7 @@ export const EnginesListTable: React.FC = ({ viewEngineIndices, }) => { const { navigateToUrl } = useValues(KibanaLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); const columns: Array> = [ { field: 'name', @@ -93,6 +95,7 @@ export const EnginesListTable: React.FC = ({ size="s" className="engineListTableFlyoutButton" data-test-subj="engineListTableIndicesFlyoutButton" + data-telemetry-id="entSearchContent-engines-table-viewEngineIndices" onClick={() => viewEngineIndices(engine.name)} > = ({ ), onClick: (engine) => { onDelete(engine); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-table-deleteEngine', + }); }, }, ], diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx index 06a2509edad53..ae7f7423be7ce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/create_engine_flyout.tsx @@ -36,7 +36,7 @@ import { ElasticsearchIndexWithIngestion } from '../../../../../common/types/ind import { isNotNullish } from '../../../../../common/utils/is_not_nullish'; import { CANCEL_BUTTON_LABEL } from '../../../shared/constants'; - +import { docLinks } from '../../../shared/doc_links'; import { getErrorsFromHttpResponse } from '../../../shared/flash_messages/handle_api_errors'; import { indexToOption, IndicesSelectComboBox } from './components/indices_select_combobox'; @@ -85,7 +85,7 @@ export const CreateEngineFlyout = ({ onClose }: CreateEngineFlyoutProps) => { values={{ enginesDocsLink: ( = ({ engineName, onClose }) => { const { deleteEngine } = useActions(EnginesListLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); const { isDeleteLoading } = useValues(EnginesListLogic); return ( = ({ engineName onCancel={onClose} onConfirm={() => { deleteEngine({ engineName }); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-engineView-deleteEngineConfirm', + }); }} cancelButtonText={CANCEL_BUTTON_LABEL} confirmButtonText={i18n.translate( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx index 76e343e234ff3..b927e499ff06c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engines/engines_list.tsx @@ -16,6 +16,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedNumber } from '@kbn/i18n-react'; import { INPUT_THROTTLE_DELAY_MS } from '../../../shared/constants/timers'; +import { docLinks } from '../../../shared/doc_links'; import { EnterpriseSearchEnginesPageTemplate } from '../layout/engines_page_template'; @@ -102,12 +103,10 @@ export const EnginesList: React.FC = () => { documentationUrl: ( - {' '} - {/* TODO: navigate to documentation url */}{' '} {i18n.translate('xpack.enterpriseSearch.content.engines.documentation', { defaultMessage: 'explore our Engines documentation', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration.tsx new file mode 100644 index 0000000000000..4741525a85b72 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 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 { useActions, useValues } from 'kea'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiSplitPanel, + EuiSwitch, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { Status } from '../../../../../../../common/types/api'; + +import { IndexViewLogic } from '../../index_view_logic'; + +import { CrawlerConfigurationLogic } from './crawler_configuration_logic'; + +export const CrawlerConfiguration: React.FC = () => { + const { htmlExtraction } = useValues(IndexViewLogic); + const { status } = useValues(CrawlerConfigurationLogic); + const { updateHtmlExtraction } = useActions(CrawlerConfigurationLogic); + return ( + <> + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.title', + { defaultMessage: 'Store full HTML' } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.addExtraFieldDescription', + { + defaultMessage: + 'Add an extra field in all documents with the value of the full HTML of the page being crawled.', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.increasedSizeWarning', + { + defaultMessage: + 'This may dramatically increase the index size if the site being crawled is large.', + } + )} +

+
+
+ + + + updateHtmlExtraction(event.target.checked)} + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.crawler.crawlerConfiguration.extractHTML.learnMoreLink', + { + defaultMessage: 'Learn more about storing full HTML.', + } + )} + + + + +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts new file mode 100644 index 0000000000000..9298ca3b32585 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/crawler/crawler_configuration/crawler_configuration_logic.ts @@ -0,0 +1,85 @@ +/* + * Copyright 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 { kea, MakeLogicType } from 'kea'; + +import { Status } from '../../../../../../../common/types/api'; +import { Connector } from '../../../../../../../common/types/connectors'; + +import { + UpdateHtmlExtractionActions, + UpdateHtmlExtractionApiLogic, +} from '../../../../api/crawler/update_html_extraction_api_logic'; +import { CachedFetchIndexApiLogicActions } from '../../../../api/index/cached_fetch_index_api_logic'; +import { isCrawlerIndex } from '../../../../utils/indices'; +import { IndexViewLogic } from '../../index_view_logic'; + +interface CrawlerConfigurationLogicActions { + apiError: UpdateHtmlExtractionActions['apiError']; + apiSuccess: UpdateHtmlExtractionActions['apiSuccess']; + fetchIndex: () => void; + fetchIndexApiSuccess: CachedFetchIndexApiLogicActions['apiSuccess']; + htmlExtraction: boolean; + makeRequest: UpdateHtmlExtractionActions['makeRequest']; + updateHtmlExtraction(htmlExtraction: boolean): { htmlExtraction: boolean }; +} + +interface CrawlerConfigurationLogicValues { + connector: Connector | undefined; + indexName: string; + localHtmlExtraction: boolean | null; + status: Status; +} + +export const CrawlerConfigurationLogic = kea< + MakeLogicType +>({ + actions: { + updateHtmlExtraction: (htmlExtraction) => ({ htmlExtraction }), + }, + connect: { + actions: [ + IndexViewLogic, + ['fetchIndex', 'fetchIndexApiSuccess'], + UpdateHtmlExtractionApiLogic, + ['apiSuccess', 'makeRequest'], + ], + values: [IndexViewLogic, ['connector', 'indexName'], UpdateHtmlExtractionApiLogic, ['status']], + }, + listeners: ({ actions, values }) => ({ + apiSuccess: () => { + actions.fetchIndex(); + }, + updateHtmlExtraction: ({ htmlExtraction }) => { + actions.makeRequest({ htmlExtraction, indexName: values.indexName }); + }, + }), + path: ['enterprise_search', 'search_index', 'crawler', 'configuration'], + reducers: { + localHtmlExtraction: [ + null, + { + apiSuccess: (_, { htmlExtraction }) => htmlExtraction, + fetchIndexApiSuccess: (_, index) => { + if (isCrawlerIndex(index)) { + return index.connector.configuration.extract_full_html?.value ?? null; + } + return null; + }, + }, + ], + }, + selectors: ({ selectors }) => ({ + htmlExtraction: [ + () => [selectors.connector, selectors.localHtmlExtraction], + (connector: Connector | null, localHtmlExtraction: boolean | null) => + localHtmlExtraction !== null + ? localHtmlExtraction + : connector?.configuration.extract_full_html?.value ?? false, + ], + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts index 26c1f823b479d..4ed5b7cef1b41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/index_view_logic.ts @@ -70,6 +70,7 @@ export interface IndexViewValues { hasAdvancedFilteringFeature: boolean; hasBasicFilteringFeature: boolean; hasFilteringFeature: boolean; + htmlExtraction: boolean | undefined; index: ElasticsearchViewIndex | undefined; indexData: typeof CachedFetchIndexApiLogic.values.indexData; indexName: string; @@ -214,6 +215,11 @@ export const IndexViewLogic = kea [selectors.hasAdvancedFilteringFeature, selectors.hasBasicFilteringFeature], (advancedFeature: boolean, basicFeature: boolean) => advancedFeature || basicFeature, ], + htmlExtraction: [ + () => [selectors.connector], + (connector: Connector | undefined) => + connector?.configuration.extract_full_html?.value ?? undefined, + ], index: [ () => [selectors.indexData], (data: IndexViewValues['indexData']) => (data ? indexToViewIndex(data) : undefined), diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx index 5c5b691d42ce1..b09a0a9d8a96a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/search_index.tsx @@ -36,6 +36,7 @@ import { ConnectorSchedulingComponent } from './connector/connector_scheduling'; import { ConnectorSyncRules } from './connector/sync_rules/connector_rules'; import { AutomaticCrawlScheduler } from './crawler/automatic_crawl_scheduler/automatic_crawl_scheduler'; import { CrawlCustomSettingsFlyout } from './crawler/crawl_custom_settings_flyout/crawl_custom_settings_flyout'; +import { CrawlerConfiguration } from './crawler/crawler_configuration/crawler_configuration'; import { SearchIndexDomainManagement } from './crawler/domain_management/domain_management'; import { SearchIndexDocuments } from './documents'; import { SearchIndexIndexMappings } from './index_mappings'; @@ -56,6 +57,7 @@ export enum SearchIndexTabId { SCHEDULING = 'scheduling', // crawler indices DOMAIN_MANAGEMENT = 'domain_management', + CRAWLER_CONFIGURATION = 'crawler_configuration', } export const SearchIndex: React.FC = () => { @@ -164,6 +166,16 @@ export const SearchIndex: React.FC = () => { defaultMessage: 'Manage Domains', }), }, + { + content: , + id: SearchIndexTabId.CRAWLER_CONFIGURATION, + name: i18n.translate( + 'xpack.enterpriseSearch.content.searchIndex.crawlerConfigurationTabLabel', + { + defaultMessage: 'Configuration', + } + ), + }, { content: , id: SearchIndexTabId.SCHEDULING, diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts index 4185addb31445..57ff05050ebae 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts @@ -32,7 +32,6 @@ export const ENGINE_TAB_PATH = `${ENGINE_PATH}/:tabId`; export enum EngineViewTabs { OVERVIEW = 'overview', INDICES = 'indices', - DOCUMENTS = 'documents', SCHEMA = 'schema', PREVIEW = 'preview', API = 'api', diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index cc1356fc0c140..073ffe6abca3f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -71,6 +71,7 @@ class DocLinks { public elasticsearchMapping: string; public elasticsearchSecureCluster: string; public enterpriseSearchConfig: string; + public enterpriseSearchEngines: string; public enterpriseSearchMailService: string; public enterpriseSearchTroubleshootSetup: string; public enterpriseSearchUsersAccess: string; @@ -186,6 +187,7 @@ class DocLinks { this.elasticsearchMapping = ''; this.elasticsearchSecureCluster = ''; this.enterpriseSearchConfig = ''; + this.enterpriseSearchEngines = ''; this.enterpriseSearchMailService = ''; this.enterpriseSearchTroubleshootSetup = ''; this.enterpriseSearchUsersAccess = ''; @@ -302,6 +304,7 @@ class DocLinks { this.elasticsearchMapping = docLinks.links.elasticsearch.mapping; this.elasticsearchSecureCluster = docLinks.links.elasticsearch.secureCluster; this.enterpriseSearchConfig = docLinks.links.enterpriseSearch.configuration; + this.enterpriseSearchEngines = docLinks.links.enterpriseSearch.engines; this.enterpriseSearchMailService = docLinks.links.enterpriseSearch.mailService; this.enterpriseSearchTroubleshootSetup = docLinks.links.enterpriseSearch.troubleshootSetup; this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx index 17ce1fce0512f..fac9d911be6c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.test.tsx @@ -468,21 +468,16 @@ describe('useEnterpriseSearchEngineNav', () => { id: 'enterpriseSearchEngineIndices', name: 'Indices', }, - { - href: `/app/enterprise_search/content/engines/${engineName}/documents`, - id: 'enterpriseSearchEngineDocuments', - name: 'Documents', - }, - { - href: `/app/enterprise_search/content/engines/${engineName}/schema`, - id: 'enterpriseSearchEngineSchema', - name: 'Schema', - }, - { - href: `/app/enterprise_search/content/engines/${engineName}/preview`, - id: 'enterpriseSearchEnginePreview', - name: 'Preview', - }, + // { + // href: `/app/enterprise_search/content/engines/${engineName}/schema`, + // id: 'enterpriseSearchEngineSchema', + // name: 'Schema', + // }, + // { + // href: `/app/enterprise_search/content/engines/${engineName}/preview`, + // id: 'enterpriseSearchEnginePreview', + // name: 'Preview', + // }, { href: `/app/enterprise_search/content/engines/${engineName}/api`, id: 'enterpriseSearchEngineAPI', diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx index a6e550e7c58b2..3d81f5d9d7c34 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/nav.tsx @@ -271,37 +271,27 @@ export const useEnterpriseSearchEngineNav = (engineName?: string, isEmptyState?: to: `${enginePath}/${EngineViewTabs.INDICES}`, }), }, - - { - id: 'enterpriseSearchEngineDocuments', - name: i18n.translate('xpack.enterpriseSearch.nav.engine.documentsTitle', { - defaultMessage: 'Documents', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: `${enginePath}/${EngineViewTabs.DOCUMENTS}`, - }), - }, - { - id: 'enterpriseSearchEngineSchema', - name: i18n.translate('xpack.enterpriseSearch.nav.engine.schemaTitle', { - defaultMessage: 'Schema', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: `${enginePath}/${EngineViewTabs.SCHEMA}`, - }), - }, - { - id: 'enterpriseSearchEnginePreview', - name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', { - defaultMessage: 'Preview', - }), - ...generateNavLink({ - shouldNotCreateHref: true, - to: `${enginePath}/${EngineViewTabs.PREVIEW}`, - }), - }, + // { + // id: 'enterpriseSearchEngineSchema', + // name: i18n.translate('xpack.enterpriseSearch.nav.engine.schemaTitle', { + // defaultMessage: 'Schema', + // }), + // ...generateNavLink({ + // shouldNotCreateHref: true, + // to: `${enginePath}/${EngineViewTabs.SCHEMA}`, + // }), + // }, + // Hidden until Preview page is available + // { + // id: 'enterpriseSearchEnginePreview', + // name: i18n.translate('xpack.enterpriseSearch.nav.engine.previewTitle', { + // defaultMessage: 'Preview', + // }), + // ...generateNavLink({ + // shouldNotCreateHref: true, + // to: `${enginePath}/${EngineViewTabs.PREVIEW}`, + // }), + // }, { id: 'enterpriseSearchEngineAPI', name: i18n.translate('xpack.enterpriseSearch.nav.engine.apiTitle', { diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts index 8c8c16e9291f9..bee878338cdc6 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.test.ts @@ -31,6 +31,7 @@ describe('Setup Indices', () => { version: CONNECTORS_VERSION, pipeline: defaultConnectorsPipelineMeta, }, + dynamic: false, properties: { api_key_id: { type: 'keyword', @@ -140,11 +141,6 @@ describe('Setup Indices', () => { run_ml_inference: { type: 'boolean' }, }, }, - preferences: { - properties: { - extract_full_html: { type: 'boolean' }, - }, - }, scheduling: { properties: { enabled: { type: 'boolean' }, @@ -161,6 +157,7 @@ describe('Setup Indices', () => { _meta: { version: CONNECTORS_VERSION, }, + dynamic: false, properties: { cancelation_requested_at: { type: 'date' }, canceled_at: { type: 'date' }, diff --git a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts index 488b0fc10930b..b70fe43303b26 100644 --- a/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts +++ b/x-pack/plugins/enterprise_search/server/index_management/setup_indices.ts @@ -130,11 +130,6 @@ const connectorMappingsProperties: Record = { run_ml_inference: { type: 'boolean' }, }, }, - preferences: { - properties: { - extract_full_html: { type: 'boolean' }, - }, - }, scheduling: { properties: { enabled: { type: 'boolean' }, @@ -174,6 +169,7 @@ const indices: IndexDefinition[] = [ pipeline: defaultConnectorsPipelineMeta, version: 1, }, + dynamic: false, properties: connectorMappingsProperties, }, name: '.elastic-connectors-v1', @@ -185,6 +181,7 @@ const indices: IndexDefinition[] = [ _meta: { version: 1, }, + dynamic: false, properties: { cancelation_requested_at: { type: 'date' }, canceled_at: { type: 'date' }, diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts index c3b52f53857d6..e6584c0a8b205 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.test.ts @@ -154,7 +154,6 @@ describe('addConnector lib function', () => { reduce_whitespace: true, run_ml_inference: false, }, - preferences: {}, scheduling: { enabled: false, interval: '0 0 0 * * ?' }, service_type: null, status: ConnectorStatus.CREATED, @@ -340,7 +339,6 @@ describe('addConnector lib function', () => { reduce_whitespace: true, run_ml_inference: false, }, - preferences: {}, scheduling: { enabled: false, interval: '0 0 0 * * ?' }, service_type: null, status: ConnectorStatus.CREATED, @@ -448,7 +446,6 @@ describe('addConnector lib function', () => { reduce_whitespace: true, run_ml_inference: false, }, - preferences: {}, scheduling: { enabled: false, interval: '0 0 0 * * ?' }, service_type: null, status: ConnectorStatus.CREATED, diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts index abe52caf453d2..507ec3fd0bb4f 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/add_connector.ts @@ -164,7 +164,6 @@ export const addConnector = async ( run_ml_inference: connectorsPipelineMeta.default_run_ml_inference, } : null, - preferences: {}, scheduling: { enabled: false, interval: '0 0 0 * * ?' }, service_type: input.service_type || null, status: ConnectorStatus.CREATED, diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts index 2ea8a875bd3b4..25956b271245a 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/start_sync.test.ts @@ -42,7 +42,6 @@ describe('addConnector lib function', () => { last_sync_error: null, last_sync_status: null, last_synced: null, - preferences: {}, scheduling: { enabled: true, interval: '1 2 3 4 5' }, service_type: null, status: 'not connected', @@ -68,7 +67,6 @@ describe('addConnector lib function', () => { last_sync_error: null, last_sync_status: null, last_synced: null, - preferences: {}, scheduling: { enabled: true, interval: '1 2 3 4 5' }, service_type: null, status: 'not connected', diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts index 89b936a41a889..2fa4c0954b245 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/update_connector_scheduling.test.ts @@ -41,7 +41,6 @@ describe('addConnector lib function', () => { last_sync_error: null, last_sync_status: null, last_synced: null, - preferences: {}, scheduling: { enabled: false, interval: '* * * * *' }, service_type: null, status: 'not connected', @@ -70,7 +69,6 @@ describe('addConnector lib function', () => { last_sync_error: null, last_sync_status: null, last_synced: null, - preferences: {}, scheduling: { enabled: true, interval: '1 2 3 4 5' }, service_type: null, status: 'not connected', diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts new file mode 100644 index 0000000000000..d859d9639b2b6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient } from '@kbn/core/server'; + +import { CONNECTORS_INDEX } from '../..'; +import { Connector } from '../../../common/types/connectors'; + +import { updateHtmlExtraction } from './put_html_extraction'; + +describe('updateHtmlExtraction lib function', () => { + const mockClient = { + asCurrentUser: { + update: jest.fn(), + }, + asInternalUser: {}, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should update connector configuration', async () => { + mockClient.asCurrentUser.update.mockResolvedValue(true); + const mockConnector = { + configuration: { test: { label: 'haha', value: 'this' } }, + id: 'connectorId', + }; + + await updateHtmlExtraction( + mockClient as unknown as IScopedClusterClient, + true, + mockConnector as any as Connector + ); + expect(mockClient.asCurrentUser.update).toHaveBeenCalledWith({ + doc: { + configuration: { + ...mockConnector.configuration, + extract_full_html: { label: 'Extract full HTML', value: true }, + }, + }, + id: 'connectorId', + index: CONNECTORS_INDEX, + refresh: 'wait_for', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts new file mode 100644 index 0000000000000..bf7c18a575853 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/lib/crawler/put_html_extraction.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; + +import { CONNECTORS_INDEX } from '../..'; +import { Connector } from '../../../common/types/connectors'; + +export async function updateHtmlExtraction( + client: IScopedClusterClient, + htmlExtraction: boolean, + connector: Connector +) { + return await client.asCurrentUser.update({ + doc: { + configuration: { + ...connector.configuration, + extract_full_html: { + label: 'Extract full HTML', + value: htmlExtraction, + }, + }, + }, + id: connector.id, + index: CONNECTORS_INDEX, + refresh: 'wait_for', + }); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts index c3b034f0b6ce7..08cea961709fc 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/crawler/crawler.ts @@ -6,6 +6,7 @@ */ import { schema } from '@kbn/config-schema'; + import { i18n } from '@kbn/i18n'; import { ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE } from '../../../../common/constants'; @@ -15,6 +16,7 @@ import { addConnector } from '../../../lib/connectors/add_connector'; import { deleteConnectorById } from '../../../lib/connectors/delete_connector'; import { fetchConnectorByIndexName } from '../../../lib/connectors/fetch_connectors'; import { fetchCrawlerByIndexName } from '../../../lib/crawler/fetch_crawlers'; +import { updateHtmlExtraction } from '../../../lib/crawler/put_html_extraction'; import { deleteIndex } from '../../../lib/indices/delete_index'; import { RouteDependencies } from '../../../plugin'; import { createError } from '../../../utils/create_error'; @@ -389,6 +391,44 @@ export function registerCrawlerRoutes(routeDependencies: RouteDependencies) { }) ); + router.put( + { + path: '/internal/enterprise_search/indices/{indexName}/crawler/html_extraction', + validate: { + body: schema.object({ + extract_full_html: schema.boolean(), + }), + params: schema.object({ + indexName: schema.string(), + }), + }, + }, + elasticsearchErrorHandler(log, async (context, request, response) => { + const { client } = (await context.core).elasticsearch; + + const connector = await fetchConnectorByIndexName(client, request.params.indexName); + if ( + connector && + connector.service_type === ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE + ) { + await updateHtmlExtraction(client, request.body.extract_full_html, connector); + return response.ok(); + } else { + return createError({ + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + message: i18n.translate( + 'xpack.enterpriseSearch.server.routes.updateHtmlExtraction.noCrawlerFound', + { + defaultMessage: 'Could not find a crawler for this index', + } + ), + response, + statusCode: 404, + }); + } + }) + ); + registerCrawlerCrawlRulesRoutes(routeDependencies); registerCrawlerEntryPointRoutes(routeDependencies); registerCrawlerSitemapRoutes(routeDependencies); diff --git a/x-pack/plugins/fleet/server/services/agents/action_status.ts b/x-pack/plugins/fleet/server/services/agents/action_status.ts index 24bb3e3b5b6b4..c50630784619a 100644 --- a/x-pack/plugins/fleet/server/services/agents/action_status.ts +++ b/x-pack/plugins/fleet/server/services/agents/action_status.ts @@ -23,7 +23,7 @@ export async function getActionStatuses( options: ListWithKuery ): Promise { const actions = await _getActions(esClient, options); - const cancelledActions = await _getCancelledActions(esClient); + const cancelledActions = await getCancelledActions(esClient); let acks: any; try { @@ -135,7 +135,7 @@ export async function getActionStatuses( return results; } -async function _getCancelledActions( +export async function getCancelledActions( esClient: ElasticsearchClient ): Promise> { const res = await esClient.search({ @@ -144,7 +144,7 @@ async function _getCancelledActions( size: SO_SEARCH_LIMIT, query: { bool: { - must: [ + filter: [ { term: { type: 'CANCEL', diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 7c88b4885b843..95f78b4de989e 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -7,6 +7,9 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { createAppContextStartContractMock } from '../../mocks'; +import { appContextService } from '../app_context'; + import { cancelAgentAction } from './actions'; import { bulkUpdateAgents } from './crud'; @@ -15,6 +18,13 @@ jest.mock('./crud'); const mockedBulkUpdateAgents = bulkUpdateAgents as jest.Mock; describe('Agent actions', () => { + beforeEach(async () => { + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + appContextService.stop(); + }); describe('cancelAgentAction', () => { it('throw if the target action is not found', async () => { const esClient = elasticsearchServiceMock.createInternalClient(); @@ -28,7 +38,7 @@ describe('Agent actions', () => { ); }); - it('should create one CANCEL action for each action found', async () => { + it('should create one CANCEL action for each UPGRADE action found', async () => { const esClient = elasticsearchServiceMock.createInternalClient(); esClient.search.mockResolvedValue({ hits: { @@ -38,6 +48,7 @@ describe('Agent actions', () => { action_id: 'action1', agents: ['agent1', 'agent2'], expiration: '2022-05-12T18:16:18.019Z', + type: 'UPGRADE', }, }, { @@ -45,6 +56,7 @@ describe('Agent actions', () => { action_id: 'action1', agents: ['agent3', 'agent4'], expiration: '2022-05-12T18:16:18.019Z', + type: 'UPGRADE', }, }, ], diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 80a23e4ecbb7b..44fde4d98b389 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -234,61 +234,110 @@ export async function getUnenrollAgentActions( } export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { - const res = await esClient.search({ - index: AGENT_ACTIONS_INDEX, - query: { - bool: { - must: [ - { - term: { - action_id: actionId, + const getUpgradeActions = async () => { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + filter: [ + { + term: { + action_id: actionId, + }, }, - }, - ], + ], + }, }, - }, - size: SO_SEARCH_LIMIT, - }); + size: SO_SEARCH_LIMIT, + }); - if (res.hits.hits.length === 0) { - throw new AgentActionNotFoundError('Action not found'); - } + if (res.hits.hits.length === 0) { + throw new AgentActionNotFoundError('Action not found'); + } + + const upgradeActions: FleetServerAgentAction[] = res.hits.hits + .map((hit) => hit._source as FleetServerAgentAction) + .filter( + (action: FleetServerAgentAction | undefined): boolean => + !!action && !!action.agents && !!action.action_id && action.type === 'UPGRADE' + ); + return upgradeActions; + }; const cancelActionId = uuidv4(); const now = new Date().toISOString(); - for (const hit of res.hits.hits) { - if (!hit._source || !hit._source.agents || !hit._source.action_id) { - continue; - } - if (hit._source.type === 'UPGRADE') { - const errors = {}; - await bulkUpdateAgents( - esClient, - hit._source.agents.map((agentId) => ({ - agentId, - data: { - upgraded_at: null, - upgrade_started_at: null, - }, - })), - errors - ); - if (Object.keys(errors).length > 0) { - appContextService - .getLogger() - .debug(`Errors while bulk updating agents for cancel action: ${JSON.stringify(errors)}`); - } - } + + const cancelledActions: Array<{ agents: string[] }> = []; + + const createAction = async (action: FleetServerAgentAction) => { await createAgentAction(esClient, { id: cancelActionId, type: 'CANCEL', - agents: hit._source.agents, + agents: action.agents!, data: { - target_id: hit._source.action_id, + target_id: action.action_id, }, created_at: now, - expiration: hit._source.expiration, + expiration: action.expiration, }); + cancelledActions.push({ + agents: action.agents!, + }); + }; + + let upgradeActions = await getUpgradeActions(); + for (const action of upgradeActions) { + await createAction(action); + } + + const updateAgentsToHealthy = async (action: FleetServerAgentAction) => { + appContextService + .getLogger() + .info( + `Moving back ${ + action.agents!.length + } agents from updating to healthy state after cancel upgrade` + ); + const errors = {}; + await bulkUpdateAgents( + esClient, + action.agents!.map((agentId: string) => ({ + agentId, + data: { + upgraded_at: null, + upgrade_started_at: null, + }, + })), + errors + ); + if (Object.keys(errors).length > 0) { + appContextService + .getLogger() + .info(`Errors while bulk updating agents for cancel action: ${JSON.stringify(errors)}`); + } + }; + + for (const action of upgradeActions) { + await updateAgentsToHealthy(action); + } + + // At the end of cancel, doing one more query on upgrade action to find those docs that were possibly created by a concurrent upgrade action. + // This is to make sure we cancel all upgrade batches. + upgradeActions = await getUpgradeActions(); + if (cancelledActions.length < upgradeActions.length) { + const missingBatches = upgradeActions.filter( + (upgradeAction) => + !cancelledActions.some( + (cancelled) => upgradeAction.agents && cancelled.agents[0] === upgradeAction.agents[0] + ) + ); + appContextService.getLogger().debug(`missing batches to cancel: ${missingBatches.length}`); + if (missingBatches.length > 0) { + for (const missingBatch of missingBatches) { + await createAction(missingBatch); + await updateAgentsToHealthy(missingBatch); + } + } } return { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts index 827044657324a..739619f7dc6b3 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts @@ -8,12 +8,22 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { appContextService } from '../app_context'; - +import type { Agent } from '../../types'; import { createAppContextStartContractMock } from '../../mocks'; import { sendUpgradeAgentsActions } from './upgrade'; import { createClientMock } from './action.mock'; -import { getRollingUpgradeOptions } from './upgrade_action_runner'; +import { getRollingUpgradeOptions, upgradeBatch } from './upgrade_action_runner'; + +jest.mock('./action_status', () => { + return { + getCancelledActions: jest.fn().mockResolvedValue([ + { + actionId: 'cancelled-action', + }, + ]), + }; +}); describe('sendUpgradeAgentsActions (plural)', () => { beforeEach(async () => { @@ -102,6 +112,14 @@ describe('sendUpgradeAgentsActions (plural)', () => { expect(doc.upgraded_at).toEqual(null); } }); + + it('skip upgrade if action id is cancelled', async () => { + const { soClient, esClient, agentInRegularDoc } = createClientMock(); + const agents = [{ id: agentInRegularDoc._id } as Agent]; + await upgradeBatch(soClient, esClient, agents, {}, { + actionId: 'cancelled-action', + } as any); + }); }); describe('getRollingUpgradeOptions', () => { diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts index ff677db23cacc..8b7409375fb2a 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts @@ -25,6 +25,7 @@ import { bulkUpdateAgents } from './crud'; import { createErrorActionResults, createAgentAction } from './actions'; import { getHostedPolicies, isHostedAgent } from './hosted_agent'; import { BulkActionTaskType } from './bulk_action_types'; +import { getCancelledActions } from './action_status'; export class UpgradeActionRunner extends ActionRunner { protected async processAgents(agents: Agent[]): Promise<{ actionId: string }> { @@ -40,6 +41,11 @@ export class UpgradeActionRunner extends ActionRunner { } } +const isActionIdCancelled = async (esClient: ElasticsearchClient, actionId: string) => { + const cancelledActions = await getCancelledActions(esClient); + return cancelledActions.filter((action) => action.actionId === actionId).length > 0; +}; + export async function upgradeBatch( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -108,6 +114,24 @@ export async function upgradeBatch( options.upgradeDurationSeconds ); + if (options.actionId && (await isActionIdCancelled(esClient, options.actionId))) { + appContextService + .getLogger() + .info( + `Skipping batch of actionId:${options.actionId} of ${givenAgents.length} agents as the upgrade was cancelled` + ); + return { + actionId: options.actionId, + }; + } + if (options.actionId) { + appContextService + .getLogger() + .info( + `Continuing batch of actionId:${options.actionId} of ${givenAgents.length} agents of upgrade` + ); + } + await bulkUpdateAgents( esClient, agentsToUpdate.map((agent) => ({ diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/dropdown_button.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/dropdown_button.tsx index 89bc192606944..0e4aa275a7879 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/dropdown_button.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/dropdown_button.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import React, { ReactNode } from 'react'; import { withTheme, EuiTheme } from '@kbn/kibana-react-plugin/common'; +import { KubernetesTour } from './kubernetes_tour'; interface Props { 'data-test-subj'?: string; @@ -15,10 +16,26 @@ interface Props { onClick: () => void; theme: EuiTheme | undefined; children: ReactNode; + showKubernetesInfo?: boolean; } +const ButtonLabel = ({ label, theme }: { label: string; theme?: EuiTheme }) => ( + + {label} + +); + export const DropdownButton = withTheme((props: Props) => { - const { onClick, label, theme, children } = props; + const { onClick, label, theme, children, showKubernetesInfo } = props; return ( { boxShadow: `0px 3px 2px ${theme?.eui.euiTableActionsBorderColor}, 0px 1px 1px ${theme?.eui.euiTableActionsBorderColor}`, }} > - - {label} - + {showKubernetesInfo ? ( + + + + ) : ( + + )} { + const [isTourSeen, setIsTourSeen] = useLocalStorage(KUBERNETES_TOUR_STORAGE_KEY, false); + const markTourAsSeen = () => setIsTourSeen(true); + + return ( +
+ + {i18n.translate('xpack.infra.homePage.kubernetesTour.text', { + defaultMessage: + 'Click here to see your infrastructure in different ways, including Kubernetes pods.', + })} + + } + isStepOpen={!isTourSeen} + maxWidth={350} + onFinish={markTourAsSeen} + step={1} + stepsTotal={1} + title={i18n.translate('xpack.infra.homePage.kubernetesTour.title', { + defaultMessage: 'Want a different view?', + })} + anchorPosition="downCenter" + footerAction={ + + {i18n.translate('xpack.infra.homePage.kubernetesTour.dismiss', { + defaultMessage: 'Dismiss', + })} + + } + > + {children} + +
+ ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/survey_kubernetes.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/survey_kubernetes.tsx new file mode 100644 index 0000000000000..b92e27c0b58f8 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/survey_kubernetes.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiGlobalToastList } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useWaffleOptionsContext } from '../hooks/use_waffle_options'; + +const KUBERNETES_TOAST_STORAGE_KEY = 'kubernetesToastKey'; +const KUBERNETES_FEEDBACK_LINK = 'https://ela.st/k8s-feedback'; + +export const SurveyKubernetes = () => { + const { nodeType } = useWaffleOptionsContext(); + const podNodeType: typeof nodeType = 'pod'; + + const [isToastSeen, setIsToastSeen] = useLocalStorage(KUBERNETES_TOAST_STORAGE_KEY, false); + const markToastAsSeen = () => setIsToastSeen(true); + + return ( + <> + {nodeType === podNodeType && ( + <> + + + + {!isToastSeen && ( + + ), + color: 'primary', + iconType: 'help', + toastLifeTimeMs: 0x7fffffff, // Biggest possible lifetime because we control when it should be visible using isToastSeen + text: ( + <> +

+ +

+ + + + + + + + + ), + }, + ]} + /> + )} + + )} + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_inventory_switcher.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_inventory_switcher.tsx index 6234dcc0bbf0b..e3ca9b770e2ef 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_inventory_switcher.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/waffle_inventory_switcher.tsx @@ -124,6 +124,7 @@ export const WaffleInventorySwitcher: React.FC = () => { data-test-subj={'openInventorySwitcher'} onClick={openPopover} label={i18n.translate('xpack.infra.waffle.showLabel', { defaultMessage: 'Show' })} + showKubernetesInfo={true} > {selectedText} diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx index 77b8e9a35a64e..fde2a4923441d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/index.tsx @@ -23,6 +23,7 @@ import { inventoryTitle } from '../../../translations'; import { SavedViews } from './components/saved_views'; import { SnapshotContainer } from './components/snapshot_container'; import { fullHeightContentStyles } from '../../../page_template.styles'; +import { SurveyKubernetes } from './components/survey_kubernetes'; export const SnapshotPage = () => { const { @@ -59,7 +60,7 @@ export const SnapshotPage = () => { hasData={metricIndicesExist} pageHeader={{ pageTitle: inventoryTitle, - rightSideItems: [], + rightSideItems: [, ], }} pageSectionProps={{ contentProps: { diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts index abd8a48815122..bb27eec2076e3 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.ts @@ -176,7 +176,9 @@ export async function loadIndexPatterns({ } indexPatterns.push( ...(await Promise.all( - Object.values(adHocDataViews || {}).map((spec) => dataViews.create(spec)) + Object.values(adHocDataViews || {}).map((spec) => + dataViews.create({ ...spec, allowNoIndex: true }) + ) )) ); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx index be43974b7ba44..3471c82a43fa4 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx @@ -980,9 +980,6 @@ describe('last_value', () => { "dimensionId": "col1", "id": "dimensionButton", }, - Object { - "id": "visualization", - }, Object { "id": "embeddableBadge", }, diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.tsx index 46a9335f20d1c..1f0e50ecb2ac6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.tsx @@ -86,7 +86,6 @@ function getInvalidSortFieldMessage( displayLocations: [ { id: 'toolbar' }, { id: 'dimensionButton', dimensionId: columnId }, - { id: 'visualization' }, { id: 'embeddableBadge' }, ], }; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index b272b5ec3e362..d4319f13e9b0f 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -156,6 +156,40 @@ describe('BuilderEntryItem', () => { expect(wrapper.find('.euiFormHelpText.euiFormRow__text').exists()).toBeFalsy(); }); + test('it render mapping issues warning text when field has mapping conflicts', () => { + wrapper = mount( + + ); + + expect(wrapper.find('.euiFormHelpText.euiFormRow__text').text()).toMatch( + /This field is defined as several types across different indices./ + ); + }); + test('it renders field values correctly when operator is "isOperator"', () => { wrapper = mount( = ({ operatorsList, allowCustomOptions = false, }): JSX.Element => { + const sPaddingSize = useEuiPaddingSize('s'); + const handleError = useCallback( (err: boolean): void => { setErrorsExist(err); @@ -194,49 +206,82 @@ export const BuilderEntryItem: React.FC = ({ onChange={handleFieldChange} acceptsCustomOptions={entry.nested == null} data-test-subj="exceptionBuilderEntryField" + showMappingConflicts={true} /> ); - if (isFirst) { + const warningIconCss = { marginRight: `${sPaddingSize}` }; + const getMappingConflictsWarning = (field: DataViewFieldBase): React.ReactNode | null => { + const conflictsInfo = getMappingConflictsInfo(field); + if (!conflictsInfo) { + return null; + } return ( - - {comboBox} - + <> + + + + {i18n.FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION} + + } + arrowDisplay="none" + > + {conflictsInfo.map((info) => { + const groupDetails = info.groupedIndices.map( + ({ name, count }) => + `${count > 1 ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(name, count) : name}` + ); + return ( + <> + + {`${ + info.totalIndexCount > 1 + ? i18n.CONFLICT_MULTIPLE_INDEX_DESCRIPTION(info.type, info.totalIndexCount) + : info.type + }: ${groupDetails.join(', ')}`} + + ); + })} + + + ); - } else { - return ( - - {comboBox} - + }; + + const customOptionText = + entry.nested == null && allowCustomOptions ? i18n.CUSTOM_COMBOBOX_OPTION_TEXT : undefined; + const helpText = + entry.field?.type !== 'conflict' ? ( + customOptionText + ) : ( + <> + {customOptionText} + {getMappingConflictsWarning(entry.field)} + ); - } + return ( + + {comboBox} + + ); }, [ indexPattern, entry, listType, listTypeSpecificIndexPatternFilter, - handleFieldChange, osTypes, isDisabled, + handleFieldChange, + sPaddingSize, allowCustomOptions, ] ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts index ee7971e69c83a..c394a9daf674c 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/translations.ts @@ -83,3 +83,16 @@ export const CUSTOM_COMBOBOX_OPTION_TEXT = i18n.translate( 'Select a field from the list. If your field is not available, create a custom one.', } ); + +export const FIELD_CONFLICT_INDICES_WARNING_DESCRIPTION = i18n.translate( + 'xpack.lists.exceptions.field.mappingConflict.description', + { + defaultMessage: 'This field is defined as several types across different indices.', + } +); + +export const CONFLICT_MULTIPLE_INDEX_DESCRIPTION = (name: string, count: number): string => + i18n.translate('xpack.lists.exceptions.field.index.description', { + defaultMessage: '{name} ({count} indices)', + values: { count, name }, + }); diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index b794f3ba39a03..bf734f14afe1d 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -50,6 +50,7 @@ "lens", "maps", "savedObjects", + "savedSearch", "usageCollection", "unifiedFieldList", "unifiedSearch" diff --git a/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx index 3ee53940add2b..b3d3a537b9be1 100644 --- a/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx +++ b/x-pack/plugins/ml/public/application/aiops/change_point_detection.tsx @@ -25,7 +25,7 @@ export const ChangePointDetectionPage: FC = () => { const context = useMlContext(); const dataView = context.currentDataView; - const savedSearch = context.currentSavedSearch; + const savedSearch = context.selectedSavedSearch; return ( <> diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx index c7abf7385c3b0..031297c55929a 100644 --- a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -25,7 +25,7 @@ export const ExplainLogRateSpikesPage: FC = () => { const context = useMlContext(); const dataView = context.currentDataView; - const savedSearch = context.currentSavedSearch; + const savedSearch = context.selectedSavedSearch; return ( <> diff --git a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx index a78a1e3815be8..cdc0c079d541c 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx @@ -25,7 +25,7 @@ export const LogCategorizationPage: FC = () => { const context = useMlContext(); const dataView = context.currentDataView; - const savedSearch = context.currentSavedSearch; + const savedSearch = context.selectedSavedSearch; return ( <> diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index d10eb2c8e63ec..5e5099937c7e1 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -78,6 +78,10 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { config: coreStart.uiSettings!, setBreadcrumbs: coreStart.chrome!.setBreadcrumbs, redirectToMlAccessDeniedPage, + getSavedSearchDeps: { + search: deps.data.search, + savedObjectsClient: coreStart.savedObjects.client, + }, }; const services = { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/types.ts b/x-pack/plugins/ml/public/application/components/data_grid/types.ts index 4e5a172e70aa8..30e711d9f41b9 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/types.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/types.ts @@ -15,6 +15,7 @@ import { EuiDataGridColumn, } from '@elastic/eui'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { Dictionary } from '../../../../common/types/common'; import { ChartData } from '../../../../common/types/field_histograms'; @@ -88,6 +89,7 @@ export interface UseIndexDataReturnType > { renderCellValue: RenderCellValue; indexPatternFields?: string[]; + timeRangeMs?: TimeRangeMs; } export interface UseDataGridReturnType { diff --git a/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts b/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts index a2ce2dcea2273..8cd013732b726 100644 --- a/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts +++ b/x-pack/plugins/ml/public/application/components/data_recognizer/data_recognizer.d.ts @@ -7,11 +7,11 @@ import { FC } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; -import { SavedSearchSavedObject } from '../../../../common/types/kibana'; +import { type SavedSearch } from '@kbn/saved-search-plugin/public'; declare const DataRecognizer: FC<{ indexPattern: DataView; - savedSearch: SavedSearchSavedObject | null; + savedSearch: SavedSearch | null; results: { count: number; onChange?: Function; diff --git a/x-pack/plugins/ml/public/application/components/field_stats_flyout/eui_combo_box_with_field_stats.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/eui_combo_box_with_field_stats.tsx new file mode 100644 index 0000000000000..bd02a4612ce52 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/eui_combo_box_with_field_stats.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useMemo } from 'react'; +import { EuiComboBoxProps } from '@elastic/eui/src/components/combo_box/combo_box'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { useFieldStatsTrigger } from './use_field_stats_trigger'; + +export const optionCss = css` + .euiComboBoxOption__enterBadge { + display: none; + } + .euiFlexGroup { + gap: 0px; + } + .euiComboBoxOption__content { + margin-left: 2px; + } +`; + +export const EuiComboBoxWithFieldStats: FC< + EuiComboBoxProps +> = ({ options, ...restProps }) => { + const { renderOption } = useFieldStatsTrigger(); + const comboBoxOptions: EuiComboBoxOptionOption[] = useMemo( + () => + Array.isArray(options) + ? options.map((o) => ({ + ...o, + css: optionCss, + })) + : [], + [options] + ); + + return ( + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_content.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_content.tsx index cf04337e06d04..ddf082cbd7e96 100644 --- a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_content.tsx +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_content.tsx @@ -5,69 +5,65 @@ * 2.0. */ -import React, { FC, useContext, useEffect, useMemo, useState } from 'react'; -import { FieldStats, FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; -import moment from 'moment'; +import React, { FC, useMemo } from 'react'; +import { + FieldStats, + FieldStatsProps, + FieldStatsServices, +} from '@kbn/unified-field-list-plugin/public'; import { isDefined } from '@kbn/ml-is-defined'; +import type { DataView } from '@kbn/data-plugin/common'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; +import moment from 'moment'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { getDefaultDatafeedQuery } from '../../jobs/new_job/utils/new_job_utils'; -import { useMlKibana } from '../../contexts/kibana'; -import { JobCreatorContext } from '../../jobs/new_job/pages/components/job_creator_context'; import { useFieldStatsFlyoutContext } from './use_field_stats_flytout_context'; -const defaultDatafeedQuery = getDefaultDatafeedQuery(); +const DEFAULT_DSL_QUERY = getDefaultDatafeedQuery(); +const DEFAULT_COLOR = euiPaletteColorBlind()[0]; -export const FieldStatsContent: FC = () => { - const { - services: { uiSettings, data, fieldFormats, charts }, - } = useMlKibana(); - const fieldStatsServices: FieldStatsServices = { - uiSettings, - dataViews: data.dataViews, - data, - fieldFormats, - charts, - }; - const { jobCreator, jobCreatorUpdated } = useContext(JobCreatorContext); +export const FieldStatsContent: FC<{ + dataView: DataView; + fieldStatsServices: FieldStatsServices; + timeRangeMs?: TimeRangeMs; + dslQuery?: FieldStatsProps['dslQuery']; +}> = ({ dataView: currentDataView, fieldStatsServices, timeRangeMs, dslQuery }) => { const { fieldName } = useFieldStatsFlyoutContext(); - const [start, setStart] = useState(jobCreator.start); - const [end, setEnd] = useState(jobCreator.end); - - useEffect(() => { - if (jobCreator.start !== start || jobCreator.end !== end) { - setStart(jobCreator.start); - setEnd(jobCreator.end); + // Format timestamp to ISO formatted date strings + const timeRange = useMemo(() => { + // Use the provided timeRange if available + if (timeRangeMs) { + return { + from: moment(timeRangeMs.from).toISOString(), + to: moment(timeRangeMs.to).toISOString(), + }; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobCreatorUpdated]); - - // Format timestamp to ISO formatted date strings - const timeRange = useMemo( - () => - start && end - ? { from: moment(start).toISOString(), to: moment(end).toISOString() } - : undefined, - [start, end] - ); + // If time range is available via jobCreator, use that + // else mimic Discover and set timeRange to be now for data view without time field + const now = moment(); + return { from: now.toISOString(), to: now.toISOString() }; + }, [timeRangeMs]); const fieldForStats = useMemo( - () => (isDefined(fieldName) ? jobCreator.dataView.getFieldByName(fieldName) : undefined), - [fieldName, jobCreator.dataView] + () => (isDefined(fieldName) ? currentDataView.getFieldByName(fieldName) : undefined), + [fieldName, currentDataView] ); - const showFieldStats = timeRange && isDefined(jobCreator.dataViewId) && fieldForStats; + const showFieldStats = timeRange && isDefined(currentDataView) && fieldForStats; return showFieldStats ? ( ) : null; }; diff --git a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout.tsx index 31d949a0defca..89d53a1fe0ba3 100644 --- a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout.tsx @@ -19,10 +19,18 @@ import { EuiSpacer, } from '@elastic/eui'; import { css } from '@emotion/react'; +import { type DataView } from '@kbn/data-plugin/common'; +import type { FieldStatsProps, FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { useFieldStatsFlyoutContext } from './use_field_stats_flytout_context'; import { FieldStatsContent } from './field_stats_content'; -export const FieldStatsFlyout: FC = () => { +export const FieldStatsFlyout: FC<{ + dataView: DataView; + fieldStatsServices: FieldStatsServices; + timeRangeMs?: TimeRangeMs; + dslQuery?: FieldStatsProps['dslQuery']; +}> = ({ dataView, fieldStatsServices, timeRangeMs, dslQuery }) => { const { setIsFlyoutVisible, isFlyoutVisible, fieldName } = useFieldStatsFlyoutContext(); const closeFlyout = useCallback(() => setIsFlyoutVisible(false), []); // eslint-disable-line react-hooks/exhaustive-deps @@ -50,7 +58,12 @@ export const FieldStatsFlyout: FC = () => { > {fieldName} - + diff --git a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout_provider.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout_provider.tsx index 49d7189451313..f65c57cc52f2f 100644 --- a/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout_provider.tsx +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_flyout_provider.tsx @@ -6,10 +6,26 @@ */ import React, { useCallback, useState } from 'react'; -import { FieldStatsFlyout } from './field_stats_flyout'; +import type { DataView } from '@kbn/data-plugin/common'; +import type { FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; +import type { FieldStatsProps } from '@kbn/unified-field-list-plugin/public'; import { MLJobWizardFieldStatsFlyoutContext } from './use_field_stats_flytout_context'; +import { FieldStatsFlyout } from './field_stats_flyout'; -export const FieldStatsFlyoutProvider = ({ children }: { children: React.ReactElement }) => { +export const FieldStatsFlyoutProvider = ({ + dataView, + fieldStatsServices, + timeRangeMs, + dslQuery, + children, +}: { + dataView: DataView; + fieldStatsServices: FieldStatsServices; + timeRangeMs?: TimeRangeMs; + dslQuery?: FieldStatsProps['dslQuery']; + children: React.ReactElement; +}) => { const [isFieldStatsFlyoutVisible, setFieldStatsIsFlyoutVisible] = useState(false); const [fieldName, setFieldName] = useState(); const [fieldValue, setFieldValue] = useState(); @@ -31,7 +47,12 @@ export const FieldStatsFlyoutProvider = ({ children }: { children: React.ReactEl fieldValue, }} > - + {children} ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/components/field_stats_info_button.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_info_button.tsx similarity index 87% rename from x-pack/plugins/ml/public/application/jobs/new_job/common/components/field_stats_info_button.tsx rename to x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_info_button.tsx index 48386731c24c0..dd500416c8fb6 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/components/field_stats_info_button.tsx +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/field_stats_info_button.tsx @@ -9,19 +9,20 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiHighlight, EuiToolTip } fr import { i18n } from '@kbn/i18n'; import React from 'react'; import { FieldIcon } from '@kbn/react-field'; -import { EVENT_RATE_FIELD_ID, Field } from '../../../../../../common/types/fields'; -import { getKbnFieldIconType } from '../../../../../../common/util/get_field_icon_types'; +import { EVENT_RATE_FIELD_ID, Field } from '../../../../common/types/fields'; +import { getKbnFieldIconType } from '../../../../common/util/get_field_icon_types'; +export type FieldForStats = Pick; export const FieldStatsInfoButton = ({ field, label, searchValue = '', onButtonClick, }: { - field: Field; + field: FieldForStats; label: string; searchValue?: string; - onButtonClick?: (field: Field) => void; + onButtonClick?: (field: FieldForStats) => void; }) => { return ( diff --git a/x-pack/plugins/ml/public/application/components/field_stats_flyout/index.ts b/x-pack/plugins/ml/public/application/components/field_stats_flyout/index.ts index bc20a20a36c06..c808653bb9ff7 100644 --- a/x-pack/plugins/ml/public/application/components/field_stats_flyout/index.ts +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/index.ts @@ -12,3 +12,6 @@ export { MLJobWizardFieldStatsFlyoutContext, useFieldStatsFlyoutContext, } from './use_field_stats_flytout_context'; +export { FieldStatsInfoButton } from './field_stats_info_button'; +export { useFieldStatsTrigger } from './use_field_stats_trigger'; +export { EuiComboBoxWithFieldStats } from './eui_combo_box_with_field_stats'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/use_field_stats_trigger.tsx b/x-pack/plugins/ml/public/application/components/field_stats_flyout/use_field_stats_trigger.tsx similarity index 68% rename from x-pack/plugins/ml/public/application/jobs/new_job/utils/use_field_stats_trigger.tsx rename to x-pack/plugins/ml/public/application/components/field_stats_flyout/use_field_stats_trigger.tsx index f13de61bef48d..0106385b3eae3 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/use_field_stats_trigger.tsx +++ b/x-pack/plugins/ml/public/application/components/field_stats_flyout/use_field_stats_trigger.tsx @@ -7,30 +7,21 @@ import React, { ReactNode, useCallback } from 'react'; import { EuiComboBoxOptionOption } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { useFieldStatsFlyoutContext } from '../../../components/field_stats_flyout'; -import { FieldStatsInfoButton } from '../common/components/field_stats_info_button'; -import { Field } from '../../../../../common/types/fields'; +import { optionCss } from './eui_combo_box_with_field_stats'; +import { useFieldStatsFlyoutContext } from '.'; +import { FieldForStats, FieldStatsInfoButton } from './field_stats_info_button'; +import { Field } from '../../../../common/types/fields'; interface Option extends EuiComboBoxOptionOption { field: Field; } -const optionCss = css` - .euiComboBoxOption__enterBadge { - display: none; - } - .euiFlexGroup { - gap: 0px; - } - .euiComboBoxOption__content { - margin-left: 2px; - } -`; - export const useFieldStatsTrigger = () => { const { setIsFlyoutVisible, setFieldName } = useFieldStatsFlyoutContext(); + + const closeFlyout = useCallback(() => setIsFlyoutVisible(false), [setIsFlyoutVisible]); + const handleFieldStatsButtonClick = useCallback( - (field: Field) => { + (field: FieldForStats) => { if (typeof field.id === 'string') { setFieldName(field.id); setIsFlyoutVisible(true); @@ -38,7 +29,6 @@ export const useFieldStatsTrigger = () => { }, [setFieldName, setIsFlyoutVisible] ); - const renderOption = useCallback( (option: EuiComboBoxOptionOption, searchValue: string): ReactNode => { const field = (option as Option).field; @@ -54,5 +44,12 @@ export const useFieldStatsTrigger = () => { }, [handleFieldStatsButtonClick] ); - return { renderOption, setIsFlyoutVisible, setFieldName, handleFieldStatsButtonClick, optionCss }; + return { + renderOption, + setIsFlyoutVisible, + setFieldName, + handleFieldStatsButtonClick, + closeFlyout, + optionCss, + }; }; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/kibana_context_value.ts b/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/kibana_context_value.ts index 642bc4baee712..1e783660a338e 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/kibana_context_value.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/kibana_context_value.ts @@ -8,7 +8,10 @@ import { dataViewMock } from './data_view'; import { dataViewsContractMock } from './data_view_contract'; import { kibanaConfigMock } from './kibana_config'; -import { savedSearchMock } from './saved_search'; +import { deprecatedSavedSearchSimpleObjMock } from './saved_search'; +import { SavedSearch } from '@kbn/saved-search-plugin/public'; + +const mockSavedSearch: SavedSearch = {} as unknown as SavedSearch; export const kibanaContextValueMock = { combinedQuery: { @@ -16,7 +19,8 @@ export const kibanaContextValueMock = { language: 'the-query-language', }, currentDataView: dataViewMock, - currentSavedSearch: savedSearchMock, + deprecatedSavedSearchObj: deprecatedSavedSearchSimpleObjMock, + selectedSavedSearch: mockSavedSearch, dataViewsContract: dataViewsContractMock, kibanaConfig: kibanaConfigMock, }; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/saved_search.ts b/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/saved_search.ts index b9475996c79d8..f16b4f311f2e4 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/saved_search.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/__mocks__/saved_search.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const savedSearchMock: any = { +export const deprecatedSavedSearchSimpleObjMock: any = { id: 'the-saved-search-id', type: 'search', attributes: { diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index 8b755b02f99b9..65a14111a8a8f 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -7,13 +7,16 @@ import React from 'react'; import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; +import type { SavedSearch } from '@kbn/saved-search-plugin/public'; import type { SavedSearchSavedObject } from '../../../../common/types/kibana'; import type { MlServicesContext } from '../../app'; export interface MlContextValue { combinedQuery: any; currentDataView: DataView; // TODO this should be DataView or null - currentSavedSearch: SavedSearchSavedObject | null; + // @deprecated currentSavedSearch is of SavedSearchSavedObject type, change to selectedSavedSearch + deprecatedSavedSearchObj: SavedSearchSavedObject | null; + selectedSavedSearch: SavedSearch | null; dataViewsContract: DataViewsContract; kibanaConfig: any; // IUiSettingsClient; kibanaVersion: string; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/use_current_saved_search.ts b/x-pack/plugins/ml/public/application/contexts/ml/use_current_saved_search.ts deleted file mode 100644 index 1d55d0d454725..0000000000000 --- a/x-pack/plugins/ml/public/application/contexts/ml/use_current_saved_search.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useContext } from 'react'; - -import { MlContext } from './ml_context'; - -export const useCurrentSavedSearch = () => { - const context = useContext(MlContext); - - if (context.currentSavedSearch === undefined) { - throw new Error('currentSavedSearch is undefined'); - } - - return context.currentSavedSearch; -}; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/use_ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/use_ml_context.ts index 1a07cb0338855..f9ee39921087f 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/use_ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/use_ml_context.ts @@ -15,7 +15,10 @@ export const useMlContext = () => { if ( context.combinedQuery === undefined || context.currentDataView === undefined || - context.currentSavedSearch === undefined || + // @deprecated currentSavedSearch is of SavedSearchSavedObject type + // and should be migrated to selectedSavedSearch + context.deprecatedSavedSearchObj === undefined || + context.selectedSavedSearch === undefined || context.dataViewsContract === undefined || context.kibanaConfig === undefined ) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index 85157569e64fb..9608457723c08 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -12,6 +12,12 @@ import { isEqual } from 'lodash'; import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { useFieldStatsTrigger } from '../../../../../components/field_stats_flyout/use_field_stats_trigger'; +import { + FieldForStats, + FieldStatsInfoButton, +} from '../../../../../components/field_stats_flyout/field_stats_info_button'; import { FieldSelectionItem } from '../../../../../../../common/types/data_frame_analytics'; // @ts-ignore could not find declaration file import { CustomSelectionTable } from '../../../../../components/custom_selection_table'; @@ -23,62 +29,6 @@ const minimumFieldsMessage = i18n.translate( } ); -const columns = [ - { - id: 'checkbox', - isCheckbox: true, - textOnly: false, - width: '32px', - }, - { - label: i18n.translate( - 'xpack.ml.dataframe.analytics.create.analysisFieldsTable.fieldNameColumn', - { - defaultMessage: 'Field name', - } - ), - id: 'name', - isSortable: true, - alignment: LEFT_ALIGNMENT, - }, - { - id: 'mapping_types', - label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.mappingColumn', { - defaultMessage: 'Mapping', - }), - isSortable: false, - alignment: LEFT_ALIGNMENT, - }, - { - label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.isIncludedColumn', { - defaultMessage: 'Is included', - }), - id: 'is_included', - alignment: LEFT_ALIGNMENT, - isSortable: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - render: ({ is_included }: { is_included: boolean }) => (is_included ? 'Yes' : 'No'), - }, - { - label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.isRequiredColumn', { - defaultMessage: 'Is required', - }), - id: 'is_required', - alignment: LEFT_ALIGNMENT, - isSortable: true, - // eslint-disable-next-line @typescript-eslint/naming-convention - render: ({ is_required }: { is_required: boolean }) => (is_required ? 'Yes' : 'No'), - }, - { - label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.reasonColumn', { - defaultMessage: 'Reason', - }), - id: 'reason', - alignment: LEFT_ALIGNMENT, - isSortable: false, - }, -]; - const checkboxDisabledCheck = (item: FieldSelectionItem) => item.is_required === true || (item.reason && item.reason.includes('unsupported type')); @@ -110,6 +60,89 @@ export const AnalysisFieldsTable: FC<{ itemsPerPage: number; }>({ pageIndex: 0, itemsPerPage: 5 }); + const { handleFieldStatsButtonClick } = useFieldStatsTrigger(); + + const columns = [ + { + id: 'checkbox', + isCheckbox: true, + textOnly: false, + width: '32px', + }, + { + label: i18n.translate( + 'xpack.ml.dataframe.analytics.create.analysisFieldsTable.fieldNameColumn', + { + defaultMessage: 'Field name', + } + ), + id: 'name', + // eslint-disable-next-line @typescript-eslint/naming-convention + render: ({ name, mapping_types }: { name: string; mapping_types: string[] }) => { + const field: FieldForStats = { + id: name, + type: (Array.isArray(mapping_types) && mapping_types.length > 0 + ? mapping_types[0] + : 'number') as ES_FIELD_TYPES, + }; + return ( + <> + + + ); + }, + + isSortable: true, + alignment: LEFT_ALIGNMENT, + }, + { + id: 'mapping_types', + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.mappingColumn', { + defaultMessage: 'Mapping', + }), + isSortable: false, + alignment: LEFT_ALIGNMENT, + }, + { + label: i18n.translate( + 'xpack.ml.dataframe.analytics.create.analyticsTable.isIncludedColumn', + { + defaultMessage: 'Is included', + } + ), + id: 'is_included', + alignment: LEFT_ALIGNMENT, + isSortable: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + render: ({ is_included }: { is_included: boolean }) => (is_included ? 'Yes' : 'No'), + }, + { + label: i18n.translate( + 'xpack.ml.dataframe.analytics.create.analyticsTable.isRequiredColumn', + { + defaultMessage: 'Is required', + } + ), + id: 'is_required', + alignment: LEFT_ALIGNMENT, + isSortable: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + render: ({ is_required }: { is_required: boolean }) => (is_required ? 'Yes' : 'No'), + }, + { + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.reasonColumn', { + defaultMessage: 'Reason', + }), + id: 'reason', + alignment: LEFT_ALIGNMENT, + isSortable: false, + }, + ]; + useEffect(() => { if (includes.length === 0 && tableItems.length > 0) { const includedFields: string[] = []; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 052c4e64272c9..9da07dc0e6116 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -8,7 +8,6 @@ import React, { FC, Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBadge, - EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, EuiPanel, @@ -20,6 +19,14 @@ import { i18n } from '@kbn/i18n'; import { debounce, cloneDeep } from 'lodash'; import { Query } from '@kbn/data-plugin/common/query'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; +import { useMlKibana } from '../../../../../contexts/kibana'; +import { + EuiComboBoxWithFieldStats, + FieldStatsFlyoutProvider, +} from '../../../../../components/field_stats_flyout'; +import { FieldForStats } from '../../../../../components/field_stats_flyout/field_stats_info_button'; import { newJobCapsServiceAnalytics } from '../../../../../services/new_job_capabilities/new_job_capabilities_service_analytics'; import { useMlContext } from '../../../../../contexts/ml'; import { getCombinedRuntimeMappings } from '../../../../../components/data_grid/common'; @@ -81,13 +88,26 @@ function getIndexDataQuery(savedSearchQuery: SavedSearchQuery, jobConfigQuery: a return savedSearchQuery !== null ? savedSearchQuery : jobConfigQuery; } +type RuntimeMappingFieldType = + | ES_FIELD_TYPES.BOOLEAN + | ES_FIELD_TYPES.DATE + | ES_FIELD_TYPES.DOUBLE + | ES_FIELD_TYPES.GEO_POINT + | ES_FIELD_TYPES.IP + | ES_FIELD_TYPES.KEYWORD + | ES_FIELD_TYPES.LONG; + +interface RuntimeOption extends EuiComboBoxOptionOption { + field: FieldForStats; +} function getRuntimeDepVarOptions(jobType: AnalyticsJobType, runtimeMappings: RuntimeMappingsType) { - const runtimeOptions: EuiComboBoxOptionOption[] = []; + const runtimeOptions: RuntimeOption[] = []; Object.keys(runtimeMappings).forEach((id) => { const field = runtimeMappings[id]; if (isRuntimeField(field) && shouldAddAsDepVarOption(id, field.type, jobType)) { runtimeOptions.push({ label: id, + field: { id, type: field.type as RuntimeMappingFieldType }, }); } }); @@ -101,7 +121,7 @@ export const ConfigurationStepForm: FC = ({ setCurrentStep, }) => { const mlContext = useMlContext(); - const { currentSavedSearch, currentDataView } = mlContext; + const { currentDataView, selectedSavedSearch } = mlContext; const { savedSearchQuery, savedSearchQueryStr } = useSavedSearch(); const [fieldOptionsFetchFail, setFieldOptionsFetchFail] = useState(false); @@ -207,6 +227,7 @@ export const ConfigurationStepForm: FC = ({ if (shouldAddAsDepVarOption(field.id, field.type, jobType)) { depVarOptions.push({ label: field.id, + field, }); if (formState.dependentVariable === field.id) { @@ -532,6 +553,17 @@ export const ConfigurationStepForm: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [dependentVariableEmpty, jobType, scatterplotMatrixProps.fields.length] ); + const { services } = useMlKibana(); + const fieldStatsServices: FieldStatsServices = useMemo(() => { + const { uiSettings, data, fieldFormats, charts } = services; + return { + uiSettings, + dataViews: data.dataViews, + data, + fieldFormats, + charts, + }; + }, [services]); // Don't render until `savedSearchQuery` has been initialized. // `undefined` means uninitialized, `null` means initialized but not used. @@ -543,219 +575,229 @@ export const ConfigurationStepForm: FC = ({ : indexPatternFieldsTableItems; return ( - - - - - {savedSearchQuery === null && ( + + + + + + {savedSearchQuery === null && ( + + + + )} + {((isClone && cloneJob) || !isClone) && } + {savedSearchQuery !== null && ( + + {i18n.translate('xpack.ml.dataframe.analytics.create.savedSearchLabel', { + defaultMessage: 'Saved search', + })} + + )} + + {selectedSavedSearch !== null + ? selectedSavedSearch.title + : currentDataView.getName()} + + + } fullWidth > - + - )} - {((isClone && cloneJob) || !isClone) && } - - {savedSearchQuery !== null && ( - - {i18n.translate('xpack.ml.dataframe.analytics.create.savedSearchLabel', { - defaultMessage: 'Saved search', - })} - - )} - - {savedSearchQuery !== null - ? currentSavedSearch?.attributes.title - : currentDataView.title} - - - } - fullWidth - > - - - {isJobTypeWithDepVar && ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.dependentVariableOptionsFetchError', + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.dependentVariableOptionsFetchError', + { + defaultMessage: + 'There was a problem fetching fields. Please refresh the page and try again.', + } + )} + , + ] + : []), + ...(fieldOptionsFetchFail === true && maxDistinctValuesError !== undefined + ? [ + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.dependentVariableMaxDistictValuesError', + { + defaultMessage: 'Invalid. {message}', + values: { message: maxDistinctValuesError }, + } + )} + , + ] + : []), + ]} + > + , - ] - : []), - ...(fieldOptionsFetchFail === true && maxDistinctValuesError !== undefined - ? [ - - {i18n.translate( - 'xpack.ml.dataframe.analytics.create.dependentVariableMaxDistictValuesError', + ) + : i18n.translate( + 'xpack.ml.dataframe.analytics.create.dependentVariableClassificationPlaceholder', { - defaultMessage: 'Invalid. {message}', - values: { message: maxDistinctValuesError }, + defaultMessage: + 'Select the numeric, categorical, or boolean field that you want to predict.', } - )} - , - ] - : []), - ]} - > - { + setFormState({ + dependentVariable: selectedOptions[0].label || '', + }); + }} + isClearable={false} + isInvalid={dependentVariable === ''} + data-test-subj={`mlAnalyticsCreateJobWizardDependentVariableSelect${ + loadingDepVarOptions ? ' loading' : ' loaded' + }`} + /> + + + )} + + + + + + {showScatterplotMatrix && ( + <> + { - setFormState({ - dependentVariable: selectedOptions[0].label || '', - }); - }} - isClearable={false} - isInvalid={dependentVariable === ''} - data-test-subj={`mlAnalyticsCreateJobWizardDependentVariableSelect${ - loadingDepVarOptions ? ' loading' : ' loaded' - }`} - /> - - - )} - - - - - - {showScatterplotMatrix && ( - <> + fullWidth + > + + + + + + + + )} + {isJobTypeWithDepVar && ( - + setFormState({ trainingPercent: +e.target.value })} + data-test-subj="mlAnalyticsCreateJobWizardTrainingPercentSlider" + /> - - - - - - )} - {isJobTypeWithDepVar && ( - - setFormState({ trainingPercent: +e.target.value })} - data-test-subj="mlAnalyticsCreateJobWizardTrainingPercentSlider" - /> - - )} - - { - setCurrentStep(ANALYTICS_STEPS.ADVANCED); - }} - /> - + )} + + { + setCurrentStep(ANALYTICS_STEPS.ADVANCED); + }} + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts index 37526b6e66ff8..ab7cd1d6c1c59 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts @@ -10,12 +10,13 @@ import { buildEsQuery, buildQueryFromFilters, decorateQuery, + Filter, fromKueryExpression, + Query, toElasticsearchQuery, } from '@kbn/es-query'; import { useMlContext } from '../../../../../contexts/ml'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search'; -import { getQueryFromSavedSearchObject } from '../../../../../util/index_utils'; // `undefined` is used for a non-initialized state // `null` is set if no saved search is used @@ -33,14 +34,16 @@ export function useSavedSearch() { const [savedSearchQueryStr, setSavedSearchQueryStr] = useState(undefined); const mlContext = useMlContext(); - const { currentSavedSearch, currentDataView, kibanaConfig } = mlContext; + const { currentDataView, kibanaConfig, selectedSavedSearch } = mlContext; const getQueryData = () => { let qry: any = {}; let qryString; - if (currentSavedSearch !== null) { - const { query, filter } = getQueryFromSavedSearchObject(currentSavedSearch); + if (selectedSavedSearch) { + // FIXME: Add support for AggregateQuery type #150091 + const query = selectedSavedSearch.searchSource.getField('query') as Query; + const filter = (selectedSavedSearch.searchSource.getField('filter') ?? []) as Filter[]; const queryLanguage = query.language; qryString = query.query; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ecdb7af9e1ddf..4f681f07ff69e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -12,6 +12,8 @@ import { EuiDataGridColumn } from '@elastic/eui'; import { CoreSetup } from '@kbn/core/public'; import type { DataView } from '@kbn/data-views-plugin/public'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { isRuntimeMappings } from '../../../../../../common/util/runtime_field_utils'; import { RuntimeMappings } from '../../../../../../common/types/fields'; import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../../common/constants/field_histograms'; @@ -85,6 +87,8 @@ export const useIndexData = ( // (for example, as part of filebeat/metricbeat/ECS based indices) // to the data grid component which would significantly slow down the page. const [indexPatternFields, setIndexPatternFields] = useState(); + const [timeRangeMs, setTimeRangeMs] = useState(); + useEffect(() => { async function fetchDataGridSampleDocuments() { setErrorMessage(''); @@ -170,6 +174,7 @@ export const useIndexData = ( setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); + const timeFieldName = indexPattern.getTimeField()?.name; const sort: EsSorting = sortingColumns.reduce((s, column) => { s[column.id] = { order: column.direction }; return s; @@ -191,11 +196,38 @@ export const useIndexData = ( ...(isRuntimeMappings(combinedRuntimeMappings) ? { runtime_mappings: combinedRuntimeMappings } : {}), + ...(timeFieldName + ? { + aggs: { + earliest: { + min: { + field: timeFieldName, + }, + }, + latest: { + max: { + field: timeFieldName, + }, + }, + }, + } + : {}), }, }; try { const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); + + if ( + resp.aggregations && + isPopulatedObject(resp.aggregations.earliest, ['value']) && + isPopulatedObject(resp.aggregations.latest, ['value']) + ) { + setTimeRangeMs({ + from: resp.aggregations.earliest.value as number, + to: resp.aggregations.latest.value as number, + } as TimeRangeMs); + } const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); setRowCountInfo({ @@ -269,5 +301,6 @@ export const useIndexData = ( ...dataGrid, indexPatternFields, renderCellValue, + timeRangeMs, }; }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx index d2ce04f8beb7e..005b1a76aef88 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx @@ -16,7 +16,7 @@ import { EuiHorizontalRule, EuiTextArea, } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { JobCreatorContext } from '../../../job_creator_context'; import { AdvancedJobCreator } from '../../../../../common/job_creator'; import { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx index 7f2e1ec3ebed1..cd6c7894ddafb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/agg_select/agg_select.tsx @@ -7,10 +7,10 @@ import React, { FC, useContext, useState, useEffect, useMemo } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; -import { FieldStatsInfoButton } from '../../../../../common/components/field_stats_info_button'; +import { FieldStatsInfoButton } from '../../../../../../../components/field_stats_flyout/field_stats_info_button'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field, Aggregation, AggFieldPair } from '../../../../../../../../../common/types/fields'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; // The display label used for an aggregation e.g. sum(bytes). export type Label = string; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx index a2992d1572b61..5e73c5e1b253b 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_field/categorization_field_select.tsx @@ -8,7 +8,7 @@ import React, { FC, useCallback, useContext, useMemo } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; import { createFieldOptions } from '../../../../../common/job_creator/util/general'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_partition_field/categorization_per_partition_input.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_partition_field/categorization_per_partition_input.tsx index a63a49bc5f893..ffa4833138bc6 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_partition_field/categorization_per_partition_input.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/categorization_partition_field/categorization_per_partition_input.tsx @@ -8,7 +8,7 @@ import React, { FC, useCallback, useContext, useMemo } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; import { createFieldOptions } from '../../../../../common/job_creator/util/general'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/geo_field/geo_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/geo_field/geo_field_select.tsx index f132023f45765..6ea00a7123e72 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/geo_field/geo_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/geo_field/geo_field_select.tsx @@ -7,7 +7,7 @@ import React, { FC, useCallback, useMemo } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { Field } from '../../../../../../../../../common/types/fields'; interface DropDownLabel { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx index 52e0b5c8c4752..5795fe9c0b2a7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers_select.tsx @@ -8,7 +8,7 @@ import React, { FC, useContext } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; import { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_field/rare_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_field/rare_field_select.tsx index 8393fe111dc33..ae36daba25804 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_field/rare_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_field/rare_field_select.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { Field, SplitField } from '../../../../../../../../../common/types/fields'; interface DropDownLabel { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field_select/split_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field_select/split_field_select.tsx index ed5a0041508b1..23e676efcff34 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field_select/split_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field_select/split_field_select.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { Field, SplitField } from '../../../../../../../../../common/types/fields'; interface DropDownLabel { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx index d91f4539a831f..fa8ce4c3f22af 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/summary_count_field/summary_count_field_select.tsx @@ -8,7 +8,7 @@ import React, { FC, useContext } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { useFieldStatsTrigger } from '../../../../../utils/use_field_stats_trigger'; +import { useFieldStatsTrigger } from '../../../../../../../components/field_stats_flyout/use_field_stats_trigger'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; import { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index 7d25031dc766c..ec08e25bb6f15 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -21,7 +21,6 @@ import { ES_FIELD_TYPES } from '@kbn/field-types'; import { useMlKibana, useNavigateToPath } from '../../../../contexts/kibana'; import { useMlContext } from '../../../../contexts/ml'; -import { isSavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; import { timeBasedIndexCheck } from '../../../../util/index_utils'; @@ -46,7 +45,7 @@ export const Page: FC = () => { const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); - const { currentSavedSearch, currentDataView } = mlContext; + const { currentDataView, selectedSavedSearch } = mlContext; const isTimeBasedIndex = timeBasedIndexCheck(currentDataView); const hasGeoFields = useMemo( @@ -58,27 +57,27 @@ export const Page: FC = () => { [currentDataView] ); const indexWarningTitle = - !isTimeBasedIndex && isSavedSearchSavedObject(currentSavedSearch) + !isTimeBasedIndex && selectedSavedSearch ? i18n.translate( 'xpack.ml.newJob.wizard.jobType.dataViewFromSavedSearchNotTimeBasedMessage', { defaultMessage: '{savedSearchTitle} uses data view {dataViewName} which is not time based', values: { - savedSearchTitle: currentSavedSearch.attributes.title as string, - dataViewName: currentDataView.title, + savedSearchTitle: selectedSavedSearch.title ?? '', + dataViewName: currentDataView.getName(), }, } ) : i18n.translate('xpack.ml.newJob.wizard.jobType.dataViewNotTimeBasedMessage', { defaultMessage: 'Data view {dataViewName} is not time based', - values: { dataViewName: currentDataView.title }, + values: { dataViewName: currentDataView.getName() }, }); - const pageTitleLabel = isSavedSearchSavedObject(currentSavedSearch) + const pageTitleLabel = selectedSavedSearch ? i18n.translate('xpack.ml.newJob.wizard.jobType.savedSearchPageTitleLabel', { defaultMessage: 'saved search {savedSearchTitle}', - values: { savedSearchTitle: currentSavedSearch.attributes.title as string }, + values: { savedSearchTitle: selectedSavedSearch.title ?? '' }, }) : i18n.translate('xpack.ml.newJob.wizard.jobType.dataViewPageTitleLabel', { defaultMessage: 'data view {dataViewName}', @@ -93,23 +92,23 @@ export const Page: FC = () => { }; const getUrlParams = () => { - return !isSavedSearchSavedObject(currentSavedSearch) + return !selectedSavedSearch ? `?index=${currentDataView.id}` - : `?savedSearchId=${currentSavedSearch.id}`; + : `?savedSearchId=${selectedSavedSearch.id}`; }; const addSelectionToRecentlyAccessed = async () => { - const title = !isSavedSearchSavedObject(currentSavedSearch) - ? currentDataView.title - : (currentSavedSearch.attributes.title as string); + const title = !selectedSavedSearch + ? currentDataView.getName() + : selectedSavedSearch.title ?? ''; const mlLocator = share.url.locators.get(ML_APP_LOCATOR)!; const dataVisualizerLink = await mlLocator.getUrl( { page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, pageState: { - ...(currentSavedSearch?.id - ? { savedSearchId: currentSavedSearch.id } + ...(selectedSavedSearch?.id + ? { savedSearchId: selectedSavedSearch.id } : { index: currentDataView.id }), }, }, @@ -295,7 +294,7 @@ export const Page: FC = () => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx index 4d27dba214242..57e0a6d75720e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/page.tsx @@ -64,7 +64,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { () => jobCreatorFactory(jobType)( mlContext.currentDataView, - mlContext.currentSavedSearch, + mlContext.deprecatedSavedSearchObj, mlContext.combinedQuery ), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -148,7 +148,7 @@ export const Page: FC = ({ existingJobsAndGroups, jobType }) => { jobCreator.modelChangeAnnotations = true; } - if (mlContext.currentSavedSearch !== null) { + if (mlContext.selectedSavedSearch !== null) { // Jobs created from saved searches cannot be cloned in the wizard as the // ML job config holds no reference to the saved search ID. jobCreator.createdBy = null; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx index ba4ea850acf84..db8374f4bec44 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/new_job/wizard_steps.tsx @@ -5,12 +5,15 @@ * 2.0. */ -import React, { Fragment, FC, useState } from 'react'; +import React, { Fragment, FC, useState, useMemo, useEffect, useContext } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiSpacer, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { type FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; +import { JobCreatorContext } from '../components/job_creator_context'; +import { useMlKibana } from '../../../../contexts/kibana'; import { FieldStatsFlyoutProvider } from '../../../../components/field_stats_flyout'; import { WIZARD_STEPS } from '../components/step_types'; @@ -30,20 +33,53 @@ interface Props { export const WizardSteps: FC = ({ currentStep, setCurrentStep }) => { const mlContext = useMlContext(); + const { services } = useMlKibana(); + const fieldStatsServices: FieldStatsServices = useMemo(() => { + const { uiSettings, data, fieldFormats, charts } = services; + return { + uiSettings, + dataViews: data.dataViews, + data, + fieldFormats, + charts, + }; + }, [services]); + + const { jobCreator, jobCreatorUpdated } = useContext(JobCreatorContext); + + const [start, setStart] = useState(jobCreator?.start); + const [end, setEnd] = useState(jobCreator?.end); + + useEffect(() => { + if ((jobCreator && jobCreator.start !== start) || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jobCreatorUpdated]); + + // Format timestamp to ISO formatted date strings + const timeRangeMs = useMemo(() => { + // If time range is available via jobCreator, use that + // else mimic Discover and set timeRange to be now for data view without time field + return start && end ? { from: start, to: start } : undefined; + }, [start, end]); + // store whether the advanced and additional sections have been expanded. // has to be stored at this level to ensure it's remembered on wizard step change const [advancedExpanded, setAdvancedExpanded] = useState(false); const [additionalExpanded, setAdditionalExpanded] = useState(false); function getSummaryStepTitle() { - if (mlContext.currentSavedSearch !== null) { + if (mlContext.selectedSavedSearch) { return i18n.translate('xpack.ml.newJob.wizard.stepComponentWrapper.summaryTitleSavedSearch', { defaultMessage: 'New job from saved search {title}', - values: { title: mlContext.currentSavedSearch.attributes.title as string }, + values: { title: mlContext.selectedSavedSearch.title ?? '' }, }); } else if (mlContext.currentDataView.id !== undefined) { return i18n.translate('xpack.ml.newJob.wizard.stepComponentWrapper.summaryTitleDataView', { defaultMessage: 'New job from data view {dataViewName}', - values: { dataViewName: mlContext.currentDataView.title }, + values: { dataViewName: mlContext.currentDataView.getName() }, }); } return ''; @@ -81,7 +117,12 @@ export const WizardSteps: FC = ({ currentStep, setCurrentStep }) => { )} {currentStep === WIZARD_STEPS.PICK_FIELDS && ( - + <> diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx index 1dbf2b2e99a84..5881f76632e70 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx @@ -92,23 +92,18 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { const [jobsAwaitingNodeCount, setJobsAwaitingNodeCount] = useState(0); // #endregion - const { - currentSavedSearch: savedSearch, - currentDataView: dataView, - combinedQuery, - } = useMlContext(); - const pageTitle = - savedSearch !== null - ? i18n.translate('xpack.ml.newJob.recognize.savedSearchPageTitle', { - defaultMessage: 'saved search {savedSearchTitle}', - values: { savedSearchTitle: savedSearch.attributes.title as string }, - }) - : i18n.translate('xpack.ml.newJob.recognize.dataViewPageTitle', { - defaultMessage: 'data view {dataViewName}', - values: { dataViewName: dataView.getName() }, - }); - const displayQueryWarning = savedSearch !== null; - const tempQuery = savedSearch === null ? undefined : combinedQuery; + const { selectedSavedSearch, currentDataView: dataView, combinedQuery } = useMlContext(); + const pageTitle = selectedSavedSearch + ? i18n.translate('xpack.ml.newJob.recognize.savedSearchPageTitle', { + defaultMessage: 'saved search {savedSearchTitle}', + values: { savedSearchTitle: selectedSavedSearch.title ?? '' }, + }) + : i18n.translate('xpack.ml.newJob.recognize.dataViewPageTitle', { + defaultMessage: 'data view {dataViewName}', + values: { dataViewName: dataView.getName() }, + }); + const displayQueryWarning = selectedSavedSearch !== null; + const tempQuery = selectedSavedSearch === null ? undefined : combinedQuery; /** * Loads recognizer module configuration. diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index e050bea78a5c9..4b9dab76994ed 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -19,6 +19,8 @@ import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { EuiLoadingContent } from '@elastic/eui'; import { UrlStateProvider } from '@kbn/ml-url-state'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { SavedObjectsClientContract } from '@kbn/core/public'; import { MlNotificationsContextProvider } from '../contexts/ml/ml_notifications_context'; import { MlContext, MlContextValue } from '../contexts/ml'; @@ -65,6 +67,10 @@ export interface PageDependencies { dataViewsContract: DataViewsContract; setBreadcrumbs: ChromeStart['setBreadcrumbs']; redirectToMlAccessDeniedPage: () => Promise; + getSavedSearchDeps: { + search: DataPublicPluginStart['search']; + savedObjectsClient: SavedObjectsClientContract; + }; } export const PageLoader: FC<{ context: MlContextValue }> = ({ context, children }) => { diff --git a/x-pack/plugins/ml/public/application/routing/routes/access_denied.tsx b/x-pack/plugins/ml/public/application/routing/routes/access_denied.tsx index 1868c521c72fd..d07f760adf791 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/access_denied.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/access_denied.tsx @@ -30,7 +30,14 @@ export const accessDeniedRouteFactory = (): MlRoute => ({ }); const PageWrapper: FC = ({ deps }) => { - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, {}); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + {} + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx index 8d85c09f5ddf1..c825c579e6497 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/change_point_detection.tsx @@ -43,10 +43,17 @@ export const changePointDetectionRouteFactory = ( const PageWrapper: FC = ({ location, deps }) => { const { index, savedSearchId }: Record = parse(location.search, { sort: false }); - const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { - checkBasicLicense, - cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), - }); + const { context } = useResolver( + index, + savedSearchId, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx index ac9728f0c54a0..b946f1a651099 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx @@ -50,15 +50,23 @@ export const explainLogRateSpikesRouteFactory = ( disabled: !AIOPS_ENABLED, }); -const PageWrapper: FC = ({ location, deps }) => { +const PageWrapper: FC = ({ location, deps, ...restProps }) => { const { redirectToMlAccessDeniedPage } = deps; const { index, savedSearchId }: Record = parse(location.search, { sort: false }); - const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { - checkBasicLicense, - cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - }); + const { context } = useResolver( + index, + savedSearchId, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/log_categorization.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/log_categorization.tsx index e3aebb87b5341..6002d86a69520 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/aiops/log_categorization.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/log_categorization.tsx @@ -50,11 +50,19 @@ const PageWrapper: FC = ({ location, deps }) => { const { redirectToMlAccessDeniedPage } = deps; const { index, savedSearchId }: Record = parse(location.search, { sort: false }); - const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { - checkBasicLicense, - cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - }); + const { context } = useResolver( + index, + savedSearchId, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx index fa0899ea23475..9466c271f31b0 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx @@ -48,11 +48,18 @@ const PageWrapper: FC = ({ location, deps }) => { sort: false, }); - const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { - ...basicResolvers(deps), - analyticsFields: () => - loadNewJobCapabilities(index, savedSearchId, deps.dataViewsContract, DATA_FRAME_ANALYTICS), - }); + const { context } = useResolver( + index, + savedSearchId, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + ...basicResolvers(deps), + analyticsFields: () => + loadNewJobCapabilities(index, savedSearchId, deps.dataViewsContract, DATA_FRAME_ANALYTICS), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx index d47780553eede..1d4e483126e7f 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx @@ -46,6 +46,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx index fd3a365d01179..0b94eb490c778 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx @@ -44,6 +44,7 @@ const PageWrapper: FC = ({ location, deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx index faae919f722c8..853be56943341 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_map.tsx @@ -45,6 +45,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_source_selection.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_source_selection.tsx index e9ffcc509b153..6d255a8fb5fb3 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_source_selection.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_source_selection.tsx @@ -44,6 +44,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx index ad01c2d97b718..29f9063cddd38 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx @@ -35,11 +35,18 @@ export const selectorRouteFactory = ( const PageWrapper: FC = ({ location, deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkBasicLicense, - checkFindFileStructurePrivilege: () => - checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx index 3d048e8d40283..3e58a2b2bd08f 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx @@ -45,12 +45,19 @@ export const fileBasedRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkBasicLicense, - cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), - checkFindFileStructurePrivilege: () => - checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx index 1ba865435280c..acd51501092b8 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx @@ -47,11 +47,19 @@ const PageWrapper: FC = ({ location, deps }) => { const { redirectToMlAccessDeniedPage } = deps; const { index, savedSearchId }: Record = parse(location.search, { sort: false }); - const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { - checkBasicLicense, - cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - }); + const { context } = useResolver( + index, + savedSearchId, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index e7e672248f625..99c3f3ff38a68 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -75,6 +75,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index 68c0cdbe6a24f..bcf8929c7f470 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -50,6 +50,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); const timefilter = useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx index a0690b4987bc0..0a9d1a30aad62 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_lens.tsx @@ -30,9 +30,16 @@ const PageWrapper: FC = ({ location, deps }) => { } ); - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - redirect: () => resolver(lensId, vis, from, to, query, filters, layerIndex), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + redirect: () => resolver(lensId, vis, from, to, query, filters, layerIndex), + } + ); return ( {} diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_map.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_map.tsx index 3f1ebb04162e6..e63a00735b348 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/from_map.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/from_map.tsx @@ -36,10 +36,17 @@ const PageWrapper: FC = ({ location, deps }) => { sort: false, }); - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - redirect: () => - resolver(dashboard, dataViewId, embeddable, geoField, splitField, from, to, layer), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + redirect: () => + resolver(dashboard, dataViewId, embeddable, geoField, splitField, from, to, layer), + } + ); return ( {} diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index 0383b48eabadc..fa35731e567c7 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -206,6 +206,7 @@ const PageWrapper: FC = ({ nextStepPath, deps, mode }) = undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, mode === MODE.NEW_JOB ? newJobResolvers : dataVizResolvers ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx index e1d38875bc1dd..e4c9fa21cbd46 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx @@ -41,6 +41,7 @@ const PageWrapper: FC = ({ location, deps }) => { savedSearchId, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx index a68bb0d861a5c..1c1c54aebc4cc 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx @@ -55,6 +55,8 @@ const PageWrapper: FC = ({ location, deps }) => { savedSearchId, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, + { ...basicResolvers(deps), existingJobsAndGroups: mlJobService.getJobAndGroupIds, @@ -77,7 +79,7 @@ const CheckViewOrCreateWrapper: FC = ({ location, deps }) => { const navigateToPath = useNavigateToPath(); // the single resolver checkViewOrCreateJobs redirects only. so will always reject - useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { + useResolver(undefined, undefined, deps.config, deps.dataViewsContract, deps.getSavedSearchDeps, { checkViewOrCreateJobs: () => checkViewOrCreateJobs(moduleId, dataViewId, createLinkWithUserDefaults, navigateToPath), }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index 8a4640a346a85..4300d73ae010e 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -200,6 +200,7 @@ const PageWrapper: FC = ({ location, jobType, deps }) => { savedSearchId, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, { ...basicResolvers(deps), privileges: () => checkCreateJobsCapabilitiesResolver(redirectToJobsManagementPage), diff --git a/x-pack/plugins/ml/public/application/routing/routes/notifications.tsx b/x-pack/plugins/ml/public/application/routing/routes/notifications.tsx index cb7cbc0c48c6e..843cf54087c8f 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/notifications.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/notifications.tsx @@ -46,12 +46,20 @@ export const notificationsRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - getMlNodeCount, - loadMlServerInfo, - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + getMlNodeCount, + loadMlServerInfo, + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx index 139e4635ce412..111a1ae77916c 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx @@ -50,12 +50,20 @@ export const overviewRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - getMlNodeCount, - loadMlServerInfo, - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + getMlNodeCount, + loadMlServerInfo, + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx index e257f0cde5aac..2d43c5ab5675c 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx @@ -41,11 +41,19 @@ export const calendarListRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - getMlNodeCount, - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + getMlNodeCount, + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx index 9753e790cd543..775ba25523a9d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx @@ -85,11 +85,19 @@ const PageWrapper: FC = ({ location, mode, deps }) => { ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE ); - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx index d4e8b61ef4870..abbe806716feb 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx @@ -41,11 +41,19 @@ export const filterListRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - getMlNodeCount, - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + getMlNodeCount, + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx index c501760080624..9afa6ec8c8557 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx @@ -89,11 +89,19 @@ const PageWrapper: FC = ({ location, mode, deps }) => { ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE ); - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx index 2e994cc4f85f9..2b9af849e2157 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx @@ -41,11 +41,19 @@ export const settingsRouteFactory = ( const PageWrapper: FC = ({ deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const { context } = useResolver(undefined, undefined, deps.config, deps.dataViewsContract, { - checkFullLicense, - checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - getMlNodeCount, - }); + const { context } = useResolver( + undefined, + undefined, + deps.config, + deps.dataViewsContract, + deps.getSavedSearchDeps, + { + checkFullLicense, + checkGetJobsCapabilities: () => + checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + getMlNodeCount, + } + ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index e89a2cff13873..729bb5ae821d8 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -79,6 +79,7 @@ const PageWrapper: FC = ({ deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, diff --git a/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx index e2b0b2709df52..46fd368829344 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/trained_models/models_list.tsx @@ -47,6 +47,7 @@ const PageWrapper: FC = ({ location, deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx index e6003f161b1c6..78d99daf0d2d7 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/trained_models/nodes_list.tsx @@ -46,6 +46,7 @@ const PageWrapper: FC = ({ location, deps }) => { undefined, deps.config, deps.dataViewsContract, + deps.getSavedSearchDeps, basicResolvers(deps) ); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true }); diff --git a/x-pack/plugins/ml/public/application/routing/use_resolver.test.ts b/x-pack/plugins/ml/public/application/routing/use_resolver.test.ts index 714df97142cca..a75448459be96 100644 --- a/x-pack/plugins/ml/public/application/routing/use_resolver.test.ts +++ b/x-pack/plugins/ml/public/application/routing/use_resolver.test.ts @@ -13,7 +13,7 @@ import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url' import { useNotifications } from '../contexts/kibana'; import type { DataViewsContract } from '@kbn/data-views-plugin/public'; -import { useResolver } from './use_resolver'; +import { type GetSavedSearchPageDeps, useResolver } from './use_resolver'; jest.mock('../contexts/kibana/use_create_url', () => { return { @@ -47,7 +47,14 @@ describe('useResolver', () => { it('should accept undefined as dataViewId and savedSearchId.', async () => { const { result, waitForNextUpdate } = renderHook(() => - useResolver(undefined, undefined, {} as IUiSettingsClient, {} as DataViewsContract, {}) + useResolver( + undefined, + undefined, + {} as IUiSettingsClient, + {} as DataViewsContract, + {} as GetSavedSearchPageDeps, + {} + ) ); await act(async () => { @@ -66,9 +73,10 @@ describe('useResolver', () => { }, }, currentDataView: null, - currentSavedSearch: null, + deprecatedSavedSearchObj: null, dataViewsContract: {}, kibanaConfig: {}, + selectedSavedSearch: null, }, results: {}, }); @@ -78,7 +86,14 @@ describe('useResolver', () => { it('should add an error toast and redirect if dataViewId is an empty string.', async () => { const { result } = renderHook(() => - useResolver('', undefined, {} as IUiSettingsClient, {} as DataViewsContract, {}) + useResolver( + '', + undefined, + {} as IUiSettingsClient, + {} as DataViewsContract, + {} as GetSavedSearchPageDeps, + {} + ) ); await act(async () => {}); diff --git a/x-pack/plugins/ml/public/application/routing/use_resolver.ts b/x-pack/plugins/ml/public/application/routing/use_resolver.ts index 6e16b1d42e6f6..0f999329b2fa1 100644 --- a/x-pack/plugins/ml/public/application/routing/use_resolver.ts +++ b/x-pack/plugins/ml/public/application/routing/use_resolver.ts @@ -6,21 +6,27 @@ */ import { useEffect, useState } from 'react'; -import { IUiSettingsClient } from '@kbn/core/public'; +import type { IUiSettingsClient, SavedObjectsClientContract } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import type { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { getSavedSearch } from '@kbn/saved-search-plugin/public'; +import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { getDataViewById, getDataViewAndSavedSearch, - DataViewAndSavedSearch, + type DataViewAndSavedSearch, } from '../util/index_utils'; import { createSearchItems } from '../jobs/new_job/utils/new_job_utils'; -import { ResolverResults, Resolvers } from './resolvers'; -import { MlContextValue } from '../contexts/ml'; +import type { ResolverResults, Resolvers } from './resolvers'; +import type { MlContextValue } from '../contexts/ml'; import { useNotifications } from '../contexts/kibana'; import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; import { ML_PAGES } from '../../../common/constants/locator'; +export interface GetSavedSearchPageDeps { + search: DataPublicPluginStart['search']; + savedObjectsClient: SavedObjectsClientContract; +} /** * Hook to resolve route specific requirements * @param dataViewId optional Kibana data view id, used for wizards @@ -34,6 +40,10 @@ export const useResolver = ( savedSearchId: string | undefined, config: IUiSettingsClient, dataViewsContract: DataViewsContract, + getSavedSearchDeps: { + search: DataPublicPluginStart['search']; + savedObjectsClient: SavedObjectsClientContract; + }, resolvers: Resolvers ): { context: MlContextValue; results: ResolverResults } => { const notifications = useNotifications(); @@ -76,25 +86,28 @@ export const useResolver = ( savedSearch: null, dataView: null, }; + let savedSearch = null; if (savedSearchId !== undefined) { + savedSearch = await getSavedSearch(savedSearchId, getSavedSearchDeps); dataViewAndSavedSearch = await getDataViewAndSavedSearch(savedSearchId); } else if (dataViewId !== undefined) { dataViewAndSavedSearch.dataView = await getDataViewById(dataViewId); } - const { savedSearch, dataView } = dataViewAndSavedSearch; + const { savedSearch: deprecatedSavedSearchObj, dataView } = dataViewAndSavedSearch; const { combinedQuery } = createSearchItems( config, dataView !== null ? dataView : undefined, - savedSearch + deprecatedSavedSearchObj ); setContext({ combinedQuery, currentDataView: dataView, - currentSavedSearch: savedSearch, + deprecatedSavedSearchObj, + selectedSavedSearch: savedSearch, dataViewsContract, kibanaConfig: config, }); diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts index 7fb27f889c417..f247be1004718 100644 --- a/x-pack/plugins/ml/public/shared.ts +++ b/x-pack/plugins/ml/public/shared.ts @@ -21,4 +21,5 @@ export * from '../common/util/date_utils'; export * from './application/formatters/metric_change_description'; export * from './application/components/data_grid'; +export * from './application/components/field_stats_flyout'; export * from './application/data_frame_analytics/common'; diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index d613793099257..5dc4bfd67dc58 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -79,5 +79,6 @@ "@kbn/shared-ux-link-redirect-app", "@kbn/react-field", "@kbn/unified-field-list-plugin", + "@kbn/saved-search-plugin", ], } diff --git a/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts b/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts index 290aae563c37b..a76e09a2cd5e2 100644 --- a/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts +++ b/x-pack/plugins/osquery/cypress/e2e/all/alerts.cy.ts @@ -18,7 +18,9 @@ import { findAndClickButton, findFormFieldByRowsLabelAndType, inputQuery, + loadAlertsEvents, submitQuery, + toggleRuleOffAndOn, typeInECSFieldInput, } from '../../tasks/live_query'; import { preparePack } from '../../tasks/packs'; @@ -61,17 +63,10 @@ describe('Alert Event Details', () => { cy.contains(`Successfully updated "${PACK_NAME}" pack`); cy.getBySel('toastCloseButton').click(); - cy.visit('/app/security/rules'); - cy.contains(RULE_NAME); - cy.wait(2000); - cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); - cy.getBySel('ruleSwitch').click(); - cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'false'); - cy.getBySel('ruleSwitch').click(); - cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); + toggleRuleOffAndOn(RULE_NAME); }); - it('adds response actations with osquery with proper validation and form values', () => { + it('adds response actions with osquery with proper validation and form values', () => { cy.visit('/app/security/rules'); cy.contains(RULE_NAME).click(); cy.contains('Edit rule settings').click(); @@ -225,27 +220,19 @@ describe('Alert Event Details', () => { cy.contains('select * from uptime'); }); cy.getBySel(RESPONSE_ACTIONS_ITEM_2).within(() => { - cy.contains('SELECT * FROM processes;'); + cy.contains("SELECT * FROM os_version where name='{{host.os.name}}';"); }); cy.getBySel(RESPONSE_ACTIONS_ITEM_3).within(() => { cy.contains('select * from users'); }); + cy.contains('Save changes').click(); + cy.contains(`${RULE_NAME} was saved`).should('exist'); + cy.getBySel('toastCloseButton').click(); }); it('should be able to run live query and add to timeline (-depending on the previous test)', () => { const TIMELINE_NAME = 'Untitled timeline'; - cy.visit('/app/security/alerts'); - cy.getBySel('header-page-title').contains('Alerts').should('exist'); - cy.getBySel('expand-event') - .first() - .within(() => { - cy.get(`[data-is-loading="true"]`).should('exist'); - }); - cy.getBySel('expand-event') - .first() - .within(() => { - cy.get(`[data-is-loading="true"]`).should('not.exist'); - }); + loadAlertsEvents(); cy.getBySel('timeline-context-menu-button').first().click({ force: true }); cy.contains('Run Osquery'); cy.getBySel('expand-event').first().click({ force: true }); @@ -272,16 +259,41 @@ describe('Alert Event Details', () => { cy.visit('/app/osquery'); closeModalIfVisible(); }); - // TODO think on how to get these actions triggered faster (because now they are not triggered during the test). - // it.skip('sees osquery results from last action', () => { - // cy.visit('/app/security/alerts'); - // cy.getBySel('header-page-title').contains('Alerts').should('exist'); - // cy.getBySel('expand-event').first().click({ force: true }); - // cy.contains('Osquery Results').click(); - // cy.getBySel('osquery-results').should('exist'); - // cy.contains('select * from uptime'); - // cy.getBySel('osqueryResultsTable').within(() => { - // checkResults(); - // }); - // }); + + it('should substitute parameters in investigation guide', () => { + loadAlertsEvents(); + cy.getBySel('expand-event').first().click({ force: true }); + cy.contains('Get processes').click(); + cy.contains("SELECT * FROM os_version where name='Ubuntu';"); + }); + + it('sees osquery results from last action', () => { + toggleRuleOffAndOn(RULE_NAME); + cy.wait(2000); + cy.visit('/app/security/alerts'); + cy.getBySel('header-page-title').contains('Alerts').should('exist'); + cy.getBySel('expand-event').first().click({ force: true }); + cy.contains('Osquery Results').click(); + cy.getBySel('osquery-results').should('exist'); + cy.contains('select * from uptime'); + cy.contains('select * from users;'); + cy.contains("SELECT * FROM os_version where name='Ubuntu';"); + cy.getBySel('osquery-results-comment').each(($comment) => { + cy.wrap($comment).within(() => { + // On initial load result table might not render due to displayed error + if ($comment.find('div .euiDataGridRow').length <= 0) { + // If tabs are present try clicking between status and results to get rid of the error message + if ($comment.find('div .euiTabs').length > 0) { + cy.getBySel('osquery-status-tab').click(); + cy.getBySel('osquery-results-tab').click(); + cy.getBySel('dataGridRowCell', { timeout: 120000 }).should('have.lengthOf.above', 0); + } + } else { + // Result tab was rendered successfully + cy.getBySel('dataGridRowCell', { timeout: 120000 }).should('have.lengthOf.above', 0); + } + // } + }); + }); + }); }); diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson index d1804c3aafec6..0eec67de7ff2e 100644 --- a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/rule.ndjson @@ -48,7 +48,7 @@ ], "query": "_id:*", "filters": [], - "note": "!{osquery{\"query\":\"SELECT * FROM processes;\",\"label\":\"Get processes\",\"ecs_mapping\":{\"process.pid\":{\"field\":\"pid\"},\"process.name\":{\"field\":\"name\"},\"process.executable\":{\"field\":\"path\"},\"process.args\":{\"field\":\"cmdline\"},\"process.working_directory\":{\"field\":\"cwd\"},\"user.id\":{\"field\":\"uid\"},\"group.id\":{\"field\":\"gid\"},\"process.parent.pid\":{\"field\":\"parent\"},\"process.pgid\":{\"field\":\"pgroup\"}}}}\n\n!{osquery{\"query\":\"select * from users;\",\"label\":\"Get users\"}}" + "note": "!{osquery{\"query\":\"SELECT * FROM os_version where name='{{host.os.name}}';\",\"label\":\"Get processes\",\"ecs_mapping\":{\"process.pid\":{\"field\":\"pid\"},\"process.name\":{\"field\":\"name\"},\"process.executable\":{\"field\":\"path\"},\"process.args\":{\"field\":\"cmdline\"},\"process.working_directory\":{\"field\":\"cwd\"},\"user.id\":{\"field\":\"uid\"},\"group.id\":{\"field\":\"gid\"},\"process.parent.pid\":{\"field\":\"parent\"},\"process.pgid\":{\"field\":\"pgroup\"}}}}\n\n!{osquery{\"query\":\"select * from users;\",\"label\":\"Get users\"}}" }, "schedule": { "interval": "5m" @@ -94,4 +94,4 @@ "alert": "8.0.0" }, "coreMigrationVersion": "8.1.0" -} \ No newline at end of file +} diff --git a/x-pack/plugins/osquery/cypress/tasks/integrations.ts b/x-pack/plugins/osquery/cypress/tasks/integrations.ts index 0c2d789f4728a..00a855207999c 100644 --- a/x-pack/plugins/osquery/cypress/tasks/integrations.ts +++ b/x-pack/plugins/osquery/cypress/tasks/integrations.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { DEFAULT_POLICY } from '../screens/fleet'; import { ADD_POLICY_BTN, CONFIRM_MODAL_BTN, @@ -13,7 +14,7 @@ import { DATA_COLLECTION_SETUP_STEP, } from '../screens/integrations'; -export const addIntegration = (agentPolicy = 'Default Fleet Server policy') => { +export const addIntegration = (agentPolicy = DEFAULT_POLICY) => { cy.getBySel(ADD_POLICY_BTN).click(); cy.getBySel(DATA_COLLECTION_SETUP_STEP).find('.euiLoadingSpinner').should('not.exist'); cy.contains('Existing hosts').click(); diff --git a/x-pack/plugins/osquery/cypress/tasks/live_query.ts b/x-pack/plugins/osquery/cypress/tasks/live_query.ts index 519ebed2ce9a6..b9d9fe581b76d 100644 --- a/x-pack/plugins/osquery/cypress/tasks/live_query.ts +++ b/x-pack/plugins/osquery/cypress/tasks/live_query.ts @@ -64,3 +64,29 @@ export const deleteAndConfirm = (type: string) => { export const findAndClickButton = (text: string) => { cy.react('EuiButton').contains(text).click(); }; + +export const toggleRuleOffAndOn = (ruleName: string) => { + cy.visit('/app/security/rules'); + cy.contains(ruleName); + cy.wait(2000); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); + cy.getBySel('ruleSwitch').click(); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'false'); + cy.getBySel('ruleSwitch').click(); + cy.getBySel('ruleSwitch').should('have.attr', 'aria-checked', 'true'); +}; + +export const loadAlertsEvents = () => { + cy.visit('/app/security/alerts'); + cy.getBySel('header-page-title').contains('Alerts').should('exist'); + cy.getBySel('expand-event') + .first() + .within(() => { + cy.get(`[data-is-loading="true"]`).should('exist'); + }); + cy.getBySel('expand-event') + .first() + .within(() => { + cy.get(`[data-is-loading="true"]`).should('not.exist'); + }); +}; diff --git a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts index 668b5b3f924df..14f31f29125e6 100644 --- a/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts +++ b/x-pack/plugins/osquery/server/routes/live_query/create_live_query_route.ts @@ -12,6 +12,7 @@ import { some, filter } from 'lodash'; import deepEqual from 'fast-deep-equal'; import type { ECSMappingOrUndefined } from '@kbn/osquery-io-ts-types'; +import { replaceParamsQuery } from '../../../common/utils/replace_params_query'; import { createLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query'; import type { CreateLiveQueryRequestBodySchema } from '../../../common/schemas/routes/live_query'; import { buildRouteValidation } from '../../utils/build_validation/route_validation'; @@ -66,9 +67,17 @@ export const createLiveQueryRoute = (router: IRouter, osqueryContext: OsqueryApp osqueryQueries, (payload: { configuration: { query: string; ecs_mapping: ECSMappingOrUndefined }; - }) => - payload?.configuration?.query === request.body.query && - deepEqual(payload?.configuration?.ecs_mapping, request.body.ecs_mapping) + }) => { + const { result: replacedConfigurationQuery } = replaceParamsQuery( + payload.configuration.query, + alertData + ); + + return ( + replacedConfigurationQuery === request.body.query && + deepEqual(payload.configuration.ecs_mapping, request.body.ecs_mapping) + ); + } ); if (!requestQueryExistsInTheInvestigationGuide) throw new Error(); diff --git a/x-pack/plugins/security_solution/common/detection_engine/constants.ts b/x-pack/plugins/security_solution/common/detection_engine/constants.ts index 6a6bf9df6d27a..bb3c1a063f56e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/constants.ts @@ -25,3 +25,5 @@ export enum RULE_PREVIEW_FROM { WEEK = 'now-65m', MONTH = 'now-25h', } + +export const PREBUILT_RULES_PACKAGE_NAME = 'security_detection_engine'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts index 2f11e1a9c120f..b2ff257ba2c4e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.test.ts @@ -24,6 +24,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -42,6 +46,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -60,6 +68,10 @@ describe('Import rules response schema', () => { exceptions_errors: [{ error: { status_code: 400, message: 'some message' } }], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -81,6 +93,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -102,6 +118,10 @@ describe('Import rules response schema', () => { ], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -120,6 +140,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -140,6 +164,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: -1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -178,6 +206,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded as UnsafeCastForTest); @@ -217,6 +249,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: 'hello', exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded as UnsafeCastForTest); @@ -238,6 +274,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -262,6 +302,10 @@ describe('Import rules response schema', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }; const decoded = ImportRulesResponse.decode(payload); const checked = exactCheck(payload, decoded); @@ -270,4 +314,182 @@ describe('Import rules response schema', () => { expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_data"']); expect(message.schema).toEqual({}); }); + + test('it should validate an empty import response with a single connectors error', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should validate an empty import response with multiple errors', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [ + { error: { status_code: 400, message: 'some message' } }, + { error: { status_code: 500, message: 'some message' } }, + ], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should NOT validate action_connectors_success that is not boolean', () => { + type UnsafeCastForTest = Either< + Errors, + { + success: boolean; + action_connectors_success: string; + success_count: number; + errors: Array< + { + id?: string | undefined; + rule_id?: string | undefined; + } & { + error: { + status_code: number; + message: string; + }; + } + >; + } + >; + const payload: Omit & { + action_connectors_success: string; + } = { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: 'invalid', + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded as UnsafeCastForTest); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "action_connectors_success"', + ]); + expect(message.schema).toEqual({}); + }); + test('it should NOT validate a action_connectors_success_count that is a negative number', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: -1, + action_connectors_errors: [], + action_connectors_warnings: [], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "-1" supplied to "action_connectors_success_count"', + ]); + expect(message.schema).toEqual({}); + }); + test('it should validate a action_connectors_warnings after importing successfully', () => { + const payload: ImportRulesResponse = { + success: false, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_errors: [], + action_connectors_warnings: [{ type: 'type', message: 'message', actionPath: 'actionPath' }], + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + test('it should NOT validate a action_connectors_warnings that is not WarningSchema', () => { + type UnsafeCastForTest = Either< + Errors, + { + success: boolean; + action_connectors_warnings: string; + success_count: number; + errors: Array< + { + id?: string | undefined; + rule_id?: string | undefined; + } & { + error: { + status_code: number; + message: string; + }; + } + >; + } + >; + const payload: Omit & { + action_connectors_warnings: string; + } = { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: 'invalid', + }; + const decoded = ImportRulesResponse.decode(payload); + const checked = exactCheck(payload, decoded as UnsafeCastForTest); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "action_connectors_warnings"', + ]); + expect(message.schema).toEqual({}); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts index 77ccd0812c2c9..99212b902c4d3 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/api/rules/import_rules/response_schema.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; -import { errorSchema } from '../../../../schemas/response/error_schema'; +import { errorSchema, warningSchema } from '../../../../schemas/response'; export type ImportRulesResponse = t.TypeOf; export const ImportRulesResponse = t.exact( @@ -19,5 +19,9 @@ export const ImportRulesResponse = t.exact( success: t.boolean, success_count: PositiveInteger, errors: t.array(errorSchema), + action_connectors_errors: t.array(errorSchema), + action_connectors_warnings: t.array(warningSchema), + action_connectors_success: t.boolean, + action_connectors_success_count: PositiveInteger, }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts index 64b82abdb3755..9a26b1a09f066 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.mock.ts @@ -8,6 +8,7 @@ import { getExceptionExportDetailsMock } from '@kbn/lists-plugin/common/schemas/response/exception_export_details_schema.mock'; import type { ExportExceptionDetailsMock } from '@kbn/lists-plugin/common/schemas/response/exception_export_details_schema.mock'; import type { ExportRulesDetails } from './export_rules_details_schema'; +import type { DefaultActionConnectorDetails } from '../../../../../server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors'; interface RuleDetailsMock { totalCount?: number; @@ -16,6 +17,23 @@ interface RuleDetailsMock { missingRules?: Array>; } +export const getActionConnectorDetailsMock = (): DefaultActionConnectorDetails => ({ + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}); +export const getOutputDetailsSampleWithActionConnectors = (): ExportRulesDetails => ({ + ...getOutputDetailsSample(), + ...getExceptionExportDetailsMock(), + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}); + export const getOutputDetailsSample = (ruleDetails?: RuleDetailsMock): ExportRulesDetails => ({ exported_count: ruleDetails?.totalCount ?? 0, exported_rules_count: ruleDetails?.rulesCount ?? 0, @@ -29,6 +47,7 @@ export const getOutputDetailsSampleWithExceptions = ( ): ExportRulesDetails => ({ ...getOutputDetailsSample(ruleDetails), ...getExceptionExportDetailsMock(exceptionDetails), + ...getActionConnectorDetailsMock(), }); export const getSampleDetailsAsNdjson = (sample: ExportRulesDetails): string => { diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts index 2c84a34b34928..a91f59924ef72 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.test.ts @@ -18,15 +18,16 @@ import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts import { getOutputDetailsSample, + getOutputDetailsSampleWithActionConnectors, getOutputDetailsSampleWithExceptions, } from './export_rules_details_schema.mock'; import type { ExportRulesDetails } from './export_rules_details_schema'; -import { exportRulesDetailsWithExceptionsSchema } from './export_rules_details_schema'; +import { exportRulesDetailsWithExceptionsAndConnectorsSchema } from './export_rules_details_schema'; -describe('exportRulesDetailsWithExceptionsSchema', () => { +describe('exportRulesDetailsWithExceptionsAndConnectorsSchema', () => { test('it should validate export details response', () => { const payload = getOutputDetailsSample(); - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -36,7 +37,7 @@ describe('exportRulesDetailsWithExceptionsSchema', () => { test('it should validate export details with exceptions details response', () => { const payload = getOutputDetailsSampleWithExceptions(); - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -44,12 +45,21 @@ describe('exportRulesDetailsWithExceptionsSchema', () => { expect(message.schema).toEqual(payload); }); + test('it should validate export details with action connectors details response', () => { + const payload = getOutputDetailsSampleWithActionConnectors(); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); test('it should strip out extra keys', () => { const payload: ExportRulesDetails & { extraKey?: string; } = getOutputDetailsSample(); payload.extraKey = 'some extra key'; - const decoded = exportRulesDetailsWithExceptionsSchema.decode(payload); + const decoded = exportRulesDetailsWithExceptionsAndConnectorsSchema.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts index 85b423135566b..205861bdd42a7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/export/export_rules_details_schema.ts @@ -9,13 +9,6 @@ import * as t from 'io-ts'; import { exportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types'; import { NonEmptyString } from '@kbn/securitysolution-io-ts-types'; -const createSchema = ( - requiredFields: Required, - optionalFields: Optional -) => { - return t.intersection([t.exact(t.type(requiredFields)), t.exact(t.partial(optionalFields))]); -}; - const exportRulesDetails = { exported_count: t.number, exported_rules_count: t.number, @@ -28,11 +21,38 @@ const exportRulesDetails = { ), missing_rules_count: t.number, }; +const excludedActionConnectors = t.intersection([ + t.exact( + t.type({ + id: NonEmptyString, + type: NonEmptyString, + }) + ), + t.exact(t.partial({ reason: t.string })), +]); + +const exportRuleActionConnectorsDetails = { + exported_action_connector_count: t.number, + missing_action_connection_count: t.number, + missing_action_connections: t.array( + t.exact( + t.type({ + id: NonEmptyString, + type: NonEmptyString, + }) + ) + ), + excluded_action_connection_count: t.number, + excluded_action_connections: t.array(excludedActionConnectors), +}; -// With exceptions -export const exportRulesDetailsWithExceptionsSchema = createSchema( - exportRulesDetails, - exportExceptionDetails -); +// With exceptions and connectors +export const exportRulesDetailsWithExceptionsAndConnectorsSchema = t.intersection([ + t.exact(t.type(exportRulesDetails)), + t.exact(t.partial(exportExceptionDetails)), + t.exact(t.partial(exportRuleActionConnectorsDetails)), +]); -export type ExportRulesDetails = t.TypeOf; +export type ExportRulesDetails = t.TypeOf< + typeof exportRulesDetailsWithExceptionsAndConnectorsSchema +>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts index d1dc9e8ac4663..cec58d1c5fdc6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/model/import/rule_to_import.mock.ts @@ -80,3 +80,41 @@ export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): RuleToIm }, ], }); + +export const webHookConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', +}; + +export const ruleWithConnectorNdJSON = (): string => { + const items = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + webHookConnector, + ]; + const stringOfExceptions = items.map((item) => JSON.stringify(item)); + + return stringOfExceptions.join('\n'); +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts index b20a956525e2e..76da687604028 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -6,3 +6,4 @@ */ export * from './error_schema'; +export * from './warning_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts new file mode 100644 index 0000000000000..1a401d1941cb0 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/warning_schema/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +const partial = t.exact( + t.partial({ + buttonLabel: t.string, + }) +); +const required = t.exact( + t.type({ + type: t.string, + message: t.string, + actionPath: t.string, + }) +); + +export const warningSchema = t.intersection([partial, required]); +export type WarningSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts index f74be26963d45..2012d756d6354 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/attach_timeline.cy.ts @@ -85,9 +85,7 @@ describe('attach timeline to case', () => { it('modal can be re-opened once closed', function () { visitTimeline(this.timelineId); attachTimelineToExistingCase(); - cy.get('[data-test-subj="all-cases-modal"] .euiButton') - .contains('Cancel') - .click({ force: true }); + cy.get('[data-test-subj="all-cases-modal-cancel-button"]').click({ force: true }); cy.get('[data-test-subj="all-cases-modal"]').should('not.exist'); attachTimelineToExistingCase(); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 20f41f938502b..a80c7099c31ff 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -573,6 +573,11 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response { .pipe(($el) => $el.trigger('click')) .should('be.checked'); }; - +const selectOverwriteConnectorsRulesImport = () => { + cy.get(RULE_IMPORT_OVERWRITE_CONNECTORS_CHECKBOX) + .pipe(($el) => $el.trigger('click')) + .should('be.checked'); +}; export const importRulesWithOverwriteAll = (rulesFile: string) => { cy.get(RULE_IMPORT_MODAL).click(); cy.get(INPUT_FILE).should('exist'); cy.get(INPUT_FILE).trigger('click', { force: true }).attachFile(rulesFile).trigger('change'); selectOverwriteRulesImport(); selectOverwriteExceptionsRulesImport(); + selectOverwriteConnectorsRulesImport(); cy.get(RULE_IMPORT_MODAL_BUTTON).last().click({ force: true }); cy.get(INPUT_FILE).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap index 47dd897f71ffb..3378225f6b602 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -90,13 +90,521 @@ Object {
+
+
+ +
+ +
+
+
+
+ + +
+
+
+ + , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ImportDataModal should import file, with warnings 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+

+ title +

+
+
+
+
+

+ description +

+
+
+
+
+ +
+
+
+
+
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+
+ +
+ +
+
+
+
+ + +
+
+
+
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ImportDataModal should import file, with warnings but no action_connectors_success_count 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+
+ +
+

+ title +

+
+
+
+
+

+ description +

+
+
+
+
+ +
+
+
+
+
+
diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap new file mode 100644 index 0000000000000..fba4aa3d0fb96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/__snapshots__/action_connectors_warning.test.tsx.snap @@ -0,0 +1,678 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ActionConnectorWarnings should not render if importedActionConnectorsCount is falsy and empty warnings array 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should not render if importedActionConnectorsCount is truthy and empty warnings array 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 1 connectors were imported and use the warning message with the correct imported number 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 1 connector has sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 2 connectors were imported and use the button label when is set 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`ActionConnectorWarnings should render if 2 connectors were imported and use the warning message with the correct imported number 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
+ , + "container":
+
+

+

+
+
+
+
+ 2 connectors have sensitive information that requires updates. +
+
+
+
+ +
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx new file mode 100644 index 0000000000000..a71ce2beeaebe --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/action_connectors_warning.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { ActionConnectorWarnings } from '.'; + +jest.mock('../../../lib/kibana/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { http: { basePath: { prepend: jest.fn() } } }, + }), +})); +describe('ActionConnectorWarnings', () => { + test('should not render if importedActionConnectorsCount is falsy and empty warnings array', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + }); + test('should not render if importedActionConnectorsCount is truthy and empty warnings array', () => { + const wrapper = render( + + ); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + }); + test('should render if 1 connectors were imported and use the warning message with the correct imported number', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '1 connector imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '1 connector has sensitive information that requires updates.' + ); + }); + test('should render if 2 connectors were imported and use the warning message with the correct imported number', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '2 connectors imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '2 connectors have sensitive information that requires updates.' + ); + expect(getByTestId('actionConnectorsWarningsCallOutButton').textContent).toBe( + 'Go to connectors' + ); + }); + test('should render if 2 connectors were imported and use the button label when is set', () => { + const wrapper = render( + + ); + const { getByTestId } = wrapper; + expect(wrapper).toMatchSnapshot(); + expect(getByTestId('actionConnectorsWarningsCallOutTitle').textContent).toBe( + '2 connectors imported' + ); + expect(getByTestId('actionConnectorsWarningsCallOutMessage').textContent).toBe( + '2 connectors have sensitive information that requires updates.' + ); + expect(getByTestId('actionConnectorsWarningsCallOutButton').textContent).toBe('Connectors'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx new file mode 100644 index 0000000000000..ad72893f66d87 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/action_connectors_warning/index.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import type { FC } from 'react'; + +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { WarningSchema } from '../../../../../common/detection_engine/schemas/response'; +import { useKibana } from '../../../lib/kibana/kibana_react'; +import * as i18n from '../translations'; + +interface ActionConnectorWarningsComponentProps { + actionConnectorsWarnings: WarningSchema[]; + importedActionConnectorsCount?: number; +} +const ActionConnectorWarningsComponent: FC = ({ + actionConnectorsWarnings, + importedActionConnectorsCount, +}) => { + const { http } = useKibana().services; + + if (!importedActionConnectorsCount || !actionConnectorsWarnings.length) return null; + const { actionPath, message, buttonLabel } = actionConnectorsWarnings[0]; + + return ( + + {i18n.ACTION_CONNECTORS_WARNING_TITLE(importedActionConnectorsCount)} + + } + color="warning" + > + + + {message} + + + + + {buttonLabel || i18n.ACTION_CONNECTORS_WARNING_BUTTON} + + + + + + ); +}; + +ActionConnectorWarningsComponent.displayName = 'ActionConnectorWarningsComponent'; + +export const ActionConnectorWarnings = React.memo(ActionConnectorWarningsComponent); + +ActionConnectorWarnings.displayName = 'ActionConnectorWarnings'; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx index 515019785534f..1986539e578e0 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.test.tsx @@ -12,6 +12,11 @@ import { ImportDataModalComponent } from '.'; jest.mock('../../lib/kibana'); +jest.mock('../../lib/kibana/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { http: { basePath: { prepend: jest.fn() } } }, + }), +})); jest.mock('../../hooks/use_app_toasts', () => ({ useAppToasts: jest.fn().mockReturnValue({ addError: jest.fn(), @@ -25,6 +30,9 @@ const importData = jest.fn().mockReturnValue({ success: true, errors: [] }); const file = new File(['file'], 'image1.png', { type: 'image/png' }); describe('ImportDataModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); test('renders correctly against snapshot', () => { const wrapper = render( { successMessage={jest.fn((totalCount) => 'successMessage')} title="title" showExceptionsCheckBox={true} + showActionConnectorsCheckBox={true} /> ); const overwriteCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-checkbox-label' + 'importDataModalCheckboxLabel' ) as HTMLInputElement; const exceptionCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-exceptions-checkbox-label' + 'importDataModalExceptionsCheckboxLabel' + ) as HTMLInputElement; + const connectorsCheckbox: HTMLInputElement = queryByTestId( + 'importDataModalActionConnectorsCheckbox' ) as HTMLInputElement; await waitFor(() => fireEvent.click(overwriteCheckbox)); await waitFor(() => fireEvent.click(exceptionCheckbox)); + await waitFor(() => fireEvent.click(connectorsCheckbox)); await waitFor(() => fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { @@ -111,6 +124,7 @@ describe('ImportDataModal', () => { ); expect(overwriteCheckbox.checked).toBeTruthy(); expect(exceptionCheckbox.checked).toBeTruthy(); + expect(connectorsCheckbox.checked).toBeTruthy(); await waitFor(() => { fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); @@ -120,6 +134,7 @@ describe('ImportDataModal', () => { expect(overwriteCheckbox.checked).toBeFalsy(); expect(exceptionCheckbox.checked).toBeFalsy(); + expect(connectorsCheckbox.checked).toBeFalsy(); }); test('should uncheck the selected checkboxes after closing the Flyout', async () => { const { queryByTestId, getAllByRole } = render( @@ -138,20 +153,25 @@ describe('ImportDataModal', () => { successMessage={jest.fn((totalCount) => 'successMessage')} title="title" showExceptionsCheckBox={true} + showActionConnectorsCheckBox={true} /> ); const closeButton = getAllByRole('button')[0]; const overwriteCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-checkbox-label' + 'importDataModalCheckboxLabel' ) as HTMLInputElement; const exceptionCheckbox: HTMLInputElement = queryByTestId( - 'import-data-modal-exceptions-checkbox-label' + 'importDataModalExceptionsCheckboxLabel' + ) as HTMLInputElement; + const connectorsCheckbox: HTMLInputElement = queryByTestId( + 'importDataModalActionConnectorsCheckbox' ) as HTMLInputElement; await waitFor(() => fireEvent.click(overwriteCheckbox)); await waitFor(() => fireEvent.click(exceptionCheckbox)); + await waitFor(() => fireEvent.click(connectorsCheckbox)); await waitFor(() => fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { @@ -168,5 +188,91 @@ describe('ImportDataModal', () => { expect(overwriteCheckbox.checked).toBeFalsy(); expect(exceptionCheckbox.checked).toBeFalsy(); + expect(connectorsCheckbox.checked).toBeFalsy(); + }); + + test('should import file, with warnings but no action_connectors_success_count', async () => { + const importWithWarning = jest.fn().mockReturnValue({ + ...importData(), + action_connectors_warnings: [ + { message: 'message', actionPath: 'path', buttonLabel: 'buttonLabel' }, + ], + action_connectors_success_count: 0, + }); + const wrapper = render( + 'successMessage')} + title="title" + /> + ); + const { queryByTestId } = wrapper; + await waitFor(() => { + fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { + target: { files: [file] }, + }); + }); + await waitFor(() => { + fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); + }); + expect(wrapper).toMatchSnapshot(); + expect(queryByTestId('actionConnectorsWarningsCallOut')).not.toBeInTheDocument(); + expect(importWithWarning).toHaveBeenCalled(); + expect(closeModal).not.toHaveBeenCalled(); + expect(importComplete).toHaveBeenCalled(); + }); + test('should import file, with warnings', async () => { + const importWithWarning = jest.fn().mockReturnValue({ + ...importData(), + action_connectors_warnings: [ + { + message: '1 connector has sensitive information that requires updates.', + actionPath: 'path', + buttonLabel: 'buttonLabel', + }, + ], + action_connectors_success_count: 1, + }); + const wrapper = render( + 'successMessage')} + title="title" + /> + ); + const { queryByTestId } = wrapper; + await waitFor(() => { + fireEvent.change(queryByTestId('rule-file-picker') as HTMLInputElement, { + target: { files: [file] }, + }); + }); + await waitFor(() => { + fireEvent.click(queryByTestId('import-data-modal-button') as HTMLButtonElement); + }); + expect(wrapper).toMatchSnapshot(); + expect(queryByTestId('actionConnectorsWarningsCallOut')).toBeInTheDocument(); + expect(importWithWarning).toHaveBeenCalled(); + expect(importComplete).toHaveBeenCalled(); + expect(closeModal).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx index a291265bc234d..1b2cf3e516e5e 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React, { useCallback, useState } from 'react'; import { EuiButton, EuiButtonEmpty, @@ -18,7 +19,7 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import React, { useCallback, useState } from 'react'; +import type { WarningSchema } from '../../../../common/detection_engine/schemas/response'; import type { ImportDataResponse, @@ -27,6 +28,7 @@ import type { import { useAppToasts } from '../../hooks/use_app_toasts'; import * as i18n from './translations'; import { showToasterMessage } from './utils'; +import { ActionConnectorWarnings } from './action_connectors_warning'; interface ImportDataModalProps { checkBoxLabel: string; @@ -43,6 +45,7 @@ interface ImportDataModalProps { subtitle: string; successMessage: (totalCount: number) => string; title: string; + showActionConnectorsCheckBox?: boolean; } /** @@ -59,6 +62,7 @@ export const ImportDataModalComponent = ({ importData, showCheckBox = true, showExceptionsCheckBox = false, + showActionConnectorsCheckBox = false, showModal, submitBtnText, subtitle, @@ -69,15 +73,35 @@ export const ImportDataModalComponent = ({ const [isImporting, setIsImporting] = useState(false); const [overwrite, setOverwrite] = useState(false); const [overwriteExceptions, setOverwriteExceptions] = useState(false); + const [overwriteActionConnectors, setOverwriteActionConnectors] = useState(false); const { addError, addSuccess } = useAppToasts(); - + const [actionConnectorsWarnings, setActionConnectorsWarnings] = useState( + [] + ); + const [importedActionConnectorsCount, setImportedActionConnectorsCount] = useState< + number | undefined + >(0); const cleanupAndCloseModal = useCallback(() => { - setIsImporting(false); - setSelectedFiles(null); closeModal(); setOverwrite(false); setOverwriteExceptions(false); - }, [setIsImporting, setSelectedFiles, closeModal, setOverwrite, setOverwriteExceptions]); + setOverwriteActionConnectors(false); + setActionConnectorsWarnings([]); + }, [closeModal, setOverwrite, setOverwriteExceptions]); + + const onImportComplete = useCallback( + (callCleanup: boolean) => { + setIsImporting(false); + setSelectedFiles(null); + importComplete(); + + if (callCleanup) { + importComplete(); + cleanupAndCloseModal(); + } + }, + [cleanupAndCloseModal, importComplete] + ); const importDataCallback = useCallback(async () => { if (selectedFiles != null) { @@ -85,25 +109,28 @@ export const ImportDataModalComponent = ({ const abortCtrl = new AbortController(); try { - const importResponse = await importData({ + const { action_connectors_warnings: warnings, ...importResponse } = await importData({ fileToImport: selectedFiles[0], overwrite, overwriteExceptions, + overwriteActionConnectors, signal: abortCtrl.signal, }); + const connectorsCount = importResponse.action_connectors_success_count; + setActionConnectorsWarnings(warnings as WarningSchema[]); + setImportedActionConnectorsCount(connectorsCount); showToasterMessage({ importResponse, exceptionsIncluded: showExceptionsCheckBox, + actionConnectorsIncluded: showActionConnectorsCheckBox, successMessage, errorMessage, errorMessageDetailed: failedDetailed, addError, addSuccess, }); - - importComplete(); - cleanupAndCloseModal(); + onImportComplete(!warnings?.length); } catch (error) { cleanupAndCloseModal(); addError(error, { title: errorMessage(1) }); @@ -114,13 +141,15 @@ export const ImportDataModalComponent = ({ importData, overwrite, overwriteExceptions, + overwriteActionConnectors, showExceptionsCheckBox, successMessage, errorMessage, failedDetailed, addError, addSuccess, - importComplete, + showActionConnectorsCheckBox, + onImportComplete, cleanupAndCloseModal, ]); @@ -136,6 +165,9 @@ export const ImportDataModalComponent = ({ setOverwriteExceptions((shouldOverwrite) => !shouldOverwrite); }, []); + const handleActionConnectorsCheckboxClick = useCallback(() => { + setOverwriteActionConnectors((shouldOverwrite) => !shouldOverwrite); + }, []); return ( <> {showModal && ( @@ -163,24 +195,41 @@ export const ImportDataModalComponent = ({ isLoading={isImporting} /> + + + + + {showCheckBox && ( <> {showExceptionsCheckBox && ( )} + {showActionConnectorsCheckBox && ( + + )} )} diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts index 763396ae62d79..11ae7dd972a57 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/translations.ts @@ -20,7 +20,12 @@ export const OVERWRITE_EXCEPTIONS_LABEL = i18n.translate( defaultMessage: 'Overwrite existing exception lists with conflicting "list_id"', } ); - +export const OVERWRITE_ACTION_CONNECTORS_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.overwriteActionConnectorsLabel', + { + defaultMessage: 'Overwrite existing connectors with conflicting action "id"', + } +); export const SUCCESSFULLY_IMPORTED_EXCEPTIONS = (totalExceptions: number) => i18n.translate( 'xpack.securitySolution.detectionEngine.components.importRuleModal.exceptionsSuccessLabel', @@ -30,6 +35,15 @@ export const SUCCESSFULLY_IMPORTED_EXCEPTIONS = (totalExceptions: number) => 'Successfully imported {totalExceptions} {totalExceptions, plural, =1 {exception} other {exceptions}}.', } ); +export const SUCCESSFULLY_IMPORTED_CONNECTORS = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.connectorsSuccessLabel', + { + values: { totalConnectors }, + defaultMessage: + 'Successfully imported {totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}}.', + } + ); export const IMPORT_FAILED = (totalExceptions: number) => i18n.translate( @@ -40,3 +54,36 @@ export const IMPORT_FAILED = (totalExceptions: number) => 'Failed to import {totalExceptions} {totalExceptions, plural, =1 {exception} other {exceptions}}', } ); +export const IMPORT_CONNECTORS_FAILED = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.importConnectorsFailedLabel', + { + values: { totalConnectors }, + defaultMessage: + 'Failed to import {totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}}', + } + ); + +export const ACTION_CONNECTORS_WARNING_TITLE = (totalConnectors: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsWarningTitle', + { + values: { totalConnectors }, + defaultMessage: + '{totalConnectors} {totalConnectors, plural, =1 {connector} other {connectors}} imported', + } + ); + +export const ACTION_CONNECTORS_WARNING_BUTTON = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsWarningButton', + { + defaultMessage: 'Go to connectors', + } +); + +export const ACTION_CONNECTORS_ADDITIONAL_PRIVILEGES = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.importRuleModal.actionConnectorsAdditionalPrivilegesError', + { + defaultMessage: 'You need additional privileges to import rules with actions.', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts index b5609a79eba6c..6b432edfb5bd1 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.test.ts @@ -9,7 +9,7 @@ import { showToasterMessage } from './utils'; describe('showToasterMessage', () => { describe('exceptionsIncluded is false', () => { - it('displays main success message if import was successful', () => { + it('should display main success message if import was successful', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -20,6 +20,7 @@ describe('showToasterMessage', () => { errors: [], }, exceptionsIncluded: false, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -32,7 +33,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays main error message if import was not successful', () => { + it('should display main error message if import was not successful', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -51,6 +52,7 @@ describe('showToasterMessage', () => { ], }, exceptionsIncluded: false, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -67,7 +69,7 @@ describe('showToasterMessage', () => { }); describe('exceptionsIncluded is true', () => { - it('displays success message for rules and exceptions if both succeed', () => { + it('should display success message for rules and exceptions if both succeed', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -82,6 +84,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 1, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -95,7 +98,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays error message for rules and exceptions if both fail', () => { + it('should display error message for rules and exceptions if both fail', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -133,6 +136,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 0, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -154,7 +158,7 @@ describe('showToasterMessage', () => { expect(addSuccess).not.toHaveBeenCalled(); }); - it('displays only a rule toaster if no exceptions were imported', () => { + it('should display only a rule toaster if no exceptions were imported', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -169,6 +173,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 0, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -181,7 +186,7 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); - it('displays only an exceptions toaster if no rules were imported', () => { + it('should display only an exceptions toaster if no rules were imported', () => { const addError = jest.fn(); const addSuccess = jest.fn(); @@ -196,6 +201,7 @@ describe('showToasterMessage', () => { exceptions_success_count: 1, }, exceptionsIncluded: true, + actionConnectorsIncluded: false, successMessage: (msg) => `success: ${msg}`, errorMessage: (msg) => `error: ${msg}`, errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, @@ -208,4 +214,277 @@ describe('showToasterMessage', () => { expect(addError).not.toHaveBeenCalled(); }); }); + + describe('actionConnectorsIncluded is false', () => { + it('should display main success message if import was successful', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + errors: [], + }, + exceptionsIncluded: false, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenCalledWith('success: 1'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display main error message if import was not successful', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 0, + errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + ], + }, + exceptionsIncluded: false, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + expect(addError).toHaveBeenCalledWith(new Error('errorDetailed: an error message'), { + title: 'error: 1', + }); + expect(addSuccess).not.toHaveBeenCalled(); + }); + }); + describe('actionConnectorsIncluded is true', () => { + it('should display success message for rules and connectors if both succeed', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_errors: [], + action_connectors_success_count: 1, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(2); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'success: 1'); + expect(addSuccess).toHaveBeenNthCalledWith(2, 'Successfully imported 1 connector.'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display 1 error message for rules and connectors even when both fail', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 1, + rules_count: 2, + action_connectors_success: false, + errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + ], + action_connectors_errors: [ + { + rule_id: 'rule_id', + error: { + status_code: 400, + message: 'an error message', + }, + }, + { + rule_id: 'rule_id_1', + error: { + status_code: 400, + message: 'another error message', + }, + }, + ], + exceptions_success: true, + exceptions_success_count: 0, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + + expect(addError).toHaveBeenCalledWith( + new Error('errorDetailed: an error message. errorDetailed: another error message'), + { + title: 'Failed to import 2 connectors', + } + ); + expect(addSuccess).not.toHaveBeenCalled(); + }); + + it('should display only a rule toaster if no connectors were imported', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_errors: [], + action_connectors_success: true, + }, + exceptionsIncluded: true, + actionConnectorsIncluded: false, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'success: 1'); + expect(addError).not.toHaveBeenCalled(); + }); + + it('should display only a connector toaster if no rules were imported', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: true, + success_count: 0, + rules_count: 0, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success_count: 1, + action_connectors_success: true, + }, + exceptionsIncluded: true, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addSuccess).toHaveBeenCalledTimes(1); + expect(addSuccess).toHaveBeenNthCalledWith(1, 'Successfully imported 1 connector.'); + expect(addError).not.toHaveBeenCalled(); + }); + it('should display the user friendly message in case of additional privileges', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + showToasterMessage({ + importResponse: { + success: false, + success_count: 1, + rules_count: 2, + action_connectors_success: false, + errors: [ + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + ], + action_connectors_errors: [ + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + { + rule_id: 'unknown', + error: { + status_code: 403, + message: + 'You may not have actions privileges required to import rules with actions', + }, + }, + ], + exceptions_success: true, + exceptions_success_count: 0, + }, + exceptionsIncluded: false, + actionConnectorsIncluded: true, + successMessage: (msg) => `success: ${msg}`, + errorMessage: (msg) => `error: ${msg}`, + errorMessageDetailed: (msg) => `errorDetailed: ${msg}`, + addError, + addSuccess, + }); + + expect(addError).toHaveBeenCalledTimes(1); + + expect(addError).toHaveBeenCalledWith( + new Error( + 'errorDetailed: You need additional privileges to import rules with actions.. errorDetailed: You need additional privileges to import rules with actions.' + ), + { + title: 'Failed to import 2 connectors', + } + ); + expect(addSuccess).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts index 8211c01a7bffa..92c6f9f666e4b 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/utils.ts @@ -30,9 +30,32 @@ export const formatError = ( return error; }; +const mapErrorMessageToUserMessage = ( + actionConnectorsErrors: Array +) => { + return actionConnectorsErrors.map((connectorError) => { + const { error } = connectorError; + const { status_code: statusCode, message: originalMessage } = error || {}; + let message; + switch (statusCode) { + case 403: + message = i18n.ACTION_CONNECTORS_ADDITIONAL_PRIVILEGES; + break; + + default: + message = originalMessage; + + break; + } + + return { ...connectorError, error: { ...error, message } }; + }); +}; + export const showToasterMessage = ({ importResponse, exceptionsIncluded, + actionConnectorsIncluded, successMessage, errorMessage, errorMessageDetailed, @@ -41,53 +64,68 @@ export const showToasterMessage = ({ }: { importResponse: ImportDataResponse; exceptionsIncluded: boolean; + actionConnectorsIncluded: boolean; successMessage: (totalCount: number) => string; errorMessage: (totalCount: number) => string; errorMessageDetailed: (message: string) => string; addError: (error: unknown, options: ErrorToastOptions) => Toast; addSuccess: (toastOrTitle: ToastInput, options?: ToastOptions | undefined) => Toast; }) => { - // if import includes exceptions - if (exceptionsIncluded) { - // rules response actions - if (importResponse.success && importResponse.success_count > 0) { - addSuccess(successMessage(importResponse.success_count)); - } - - if (importResponse.errors.length > 0 && importResponse.rules_count) { - const error = formatError(errorMessageDetailed, importResponse, importResponse.errors); - addError(error, { - title: errorMessage(importResponse.rules_count - importResponse.success_count), - }); - } - - // exceptions response actions + if (importResponse.success) { + if (importResponse.success_count > 0) addSuccess(successMessage(importResponse.success_count)); if ( + exceptionsIncluded && importResponse.exceptions_success && importResponse.exceptions_success_count != null && importResponse.exceptions_success_count > 0 ) { addSuccess(i18n.SUCCESSFULLY_IMPORTED_EXCEPTIONS(importResponse.exceptions_success_count)); } + if ( + actionConnectorsIncluded && + importResponse.action_connectors_success && + importResponse.action_connectors_success_count != null && + importResponse.action_connectors_success_count > 0 + ) { + addSuccess( + i18n.SUCCESSFULLY_IMPORTED_CONNECTORS(importResponse.action_connectors_success_count) + ); + } + return; + } + + if (importResponse.errors.length > 0) { + if ( + actionConnectorsIncluded && + importResponse.action_connectors_errors != null && + importResponse.action_connectors_errors.length > 0 + ) { + const userErrorMessages = mapErrorMessageToUserMessage( + importResponse.action_connectors_errors + ); + const connectorError = formatError(errorMessageDetailed, importResponse, userErrorMessages); - if (importResponse.exceptions_errors != null && importResponse.exceptions_errors.length > 0) { - const error = formatError( + return addError(connectorError, { + title: i18n.IMPORT_CONNECTORS_FAILED(userErrorMessages.length), + }); + } + const ruleError = formatError(errorMessageDetailed, importResponse, importResponse.errors); + addError(ruleError, { title: errorMessage(importResponse.errors.length) }); + + if ( + exceptionsIncluded && + importResponse.exceptions_errors != null && + importResponse.exceptions_errors.length > 0 + ) { + const exceptionError = formatError( errorMessageDetailed, importResponse, importResponse.exceptions_errors ); - addError(error, { title: i18n.IMPORT_FAILED(importResponse.exceptions_errors.length) }); - } - } else { - // rules response actions - if (importResponse.success) { - addSuccess(successMessage(importResponse.success_count)); - } - - if (importResponse.errors.length > 0) { - const error = formatError(errorMessageDetailed, importResponse, importResponse.errors); - addError(error, { title: errorMessage(importResponse.errors.length) }); + addError(exceptionError, { + title: i18n.IMPORT_FAILED(importResponse.exceptions_errors.length), + }); } } }; diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 3f5bae86b476a..e154eb1f5edfe 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -47,7 +47,18 @@ export const getIndexFields = memoizeOne( fields && fields.length > 0 ? { fields: fields.map((field) => - pick(['name', 'searchable', 'type', 'aggregatable', 'esTypes', 'subType'], field) + pick( + [ + 'name', + 'searchable', + 'type', + 'aggregatable', + 'esTypes', + 'subType', + 'conflictDescriptions', + ], + field + ) ), title, } @@ -99,7 +110,8 @@ interface FetchIndexReturn { export const useFetchIndex = ( indexNames: string[], onlyCheckIfIndicesExist: boolean = false, - strategy: string = 'indexFields' + strategy: string = 'indexFields', + includeUnmapped: boolean = false ): [boolean, FetchIndexReturn] => { const { data } = useKibana().services; const abortCtrl = useRef(new AbortController()); @@ -122,7 +134,7 @@ export const useFetchIndex = ( setLoading(true); searchSubscription$.current = data.search .search, IndexFieldsStrategyResponse>( - { indices: iNames, onlyCheckIfIndicesExist }, + { indices: iNames, onlyCheckIfIndicesExist, includeUnmapped }, { abortSignal: abortCtrl.current.signal, strategy, @@ -133,7 +145,9 @@ export const useFetchIndex = ( if (isCompleteResponse(response)) { Promise.resolve().then(() => { ReactDOM.unstable_batchedUpdates(() => { - const stringifyIndices = response.indicesExist.sort().join(); + const stringifyIndices = `${response.indicesExist + .sort() + .join()} (includeUnmapped: ${includeUnmapped})`; previousIndexesName.current = response.indicesExist; const { browserFields } = getDataViewStateFromIndexFields( @@ -170,7 +184,16 @@ export const useFetchIndex = ( abortCtrl.current.abort(); asyncSearch(); }, - [data.search, addError, addWarning, onlyCheckIfIndicesExist, setLoading, setState, strategy] + [ + data.search, + addError, + addWarning, + onlyCheckIfIndicesExist, + includeUnmapped, + setLoading, + setState, + strategy, + ] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts b/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts index 1354770b7384d..3ffb3ca149b20 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts @@ -12,6 +12,7 @@ import { epmRouteService } from '@kbn/fleet-plugin/common'; import type { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; import { KibanaServices, useKibana } from '../lib/kibana'; import { useUserPrivileges } from '../components/user_privileges'; +import { PREBUILT_RULES_PACKAGE_NAME } from '../../../common/detection_engine/constants'; /** * Requests that the endpoint and security_detection_engine package be upgraded to the latest version @@ -25,16 +26,16 @@ const sendUpgradeSecurityPackages = async ( options: HttpFetchOptions = {}, prebuiltRulesPackageVersion?: string ): Promise => { - const packages = ['endpoint', 'security_detection_engine']; + const packages = ['endpoint', PREBUILT_RULES_PACKAGE_NAME]; const requests: Array> = []; // If `prebuiltRulesPackageVersion` is provided, try to install that version // Must be done as two separate requests as bulk API doesn't support versions if (prebuiltRulesPackageVersion != null) { - packages.splice(packages.indexOf('security_detection_engine'), 1); + packages.splice(packages.indexOf(PREBUILT_RULES_PACKAGE_NAME), 1); requests.push( http.post( - epmRouteService.getInstallPath('security_detection_engine', prebuiltRulesPackageVersion), + epmRouteService.getInstallPath(PREBUILT_RULES_PACKAGE_NAME, prebuiltRulesPackageVersion), { ...options, body: JSON.stringify({ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx index 7fcd1fae95c83..3c48f7594dc99 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/logic/use_exception_flyout_data.tsx @@ -80,8 +80,12 @@ export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExcep } }, [jobs, isMLRule, memoDataViewId, memoNonDataViewIndexPatterns]); - const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = - useFetchIndex(memoRuleIndices); + const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] = useFetchIndex( + memoRuleIndices, + false, + 'indexFields', + true + ); // Data view logic const [dataViewIndexPatterns, setDataViewIndexPatterns] = useState(null); @@ -93,8 +97,15 @@ export const useFetchIndexPatterns = (rules: Rule[] | null): ReturnUseFetchExcep if (activeSpaceId !== '' && memoDataViewId) { setDataViewLoading(true); const dv = await data.dataViews.get(memoDataViewId); + const fieldsWithUnmappedInfo = await data.dataViews.getFieldsForIndexPattern(dv, { + pattern: '', + includeUnmapped: true, + }); setDataViewLoading(false); - setDataViewIndexPatterns(dv); + setDataViewIndexPatterns({ + ...dv, + fields: fieldsWithUnmappedInfo, + }); } }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index bf1ac32d2bc78..a1d59efa4c30a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -493,6 +493,7 @@ describe('Detections Rules API', () => { }, query: { overwrite: false, + overwrite_action_connectors: false, overwrite_exceptions: false, }, }); @@ -510,6 +511,7 @@ describe('Detections Rules API', () => { query: { overwrite: true, overwrite_exceptions: false, + overwrite_action_connectors: false, }, }); }); @@ -523,6 +525,10 @@ describe('Detections Rules API', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); const resp = await importRules({ fileToImport, signal: abortCtrl.signal }); expect(resp).toEqual({ @@ -533,6 +539,10 @@ describe('Detections Rules API', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index c867b6b3fd792..aec165167db59 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -341,6 +341,7 @@ export const importRules = async ({ fileToImport, overwrite = false, overwriteExceptions = false, + overwriteActionConnectors = false, signal, }: ImportDataProps): Promise => { const formData = new FormData(); @@ -351,7 +352,11 @@ export const importRules = async ({ { method: 'POST', headers: { 'Content-Type': undefined }, - query: { overwrite, overwrite_exceptions: overwriteExceptions }, + query: { + overwrite, + overwrite_exceptions: overwriteExceptions, + overwrite_action_connectors: overwriteActionConnectors, + }, body: formData, signal, } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 6b520659090d9..246a000356761 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -29,6 +29,7 @@ import { import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; +import type { WarningSchema } from '../../../../common/detection_engine/schemas/response'; import { RuleExecutionSummary } from '../../../../common/detection_engine/rule_monitoring'; import { AlertSuppression, @@ -267,6 +268,7 @@ export interface ImportDataProps { fileToImport: File; overwrite?: boolean; overwriteExceptions?: boolean; + overwriteActionConnectors?: boolean; signal: AbortSignal; } @@ -304,6 +306,10 @@ export interface ImportDataResponse { exceptions_success?: boolean; exceptions_success_count?: number; exceptions_errors?: ExceptionsImportError[]; + action_connectors_success?: boolean; + action_connectors_success_count?: number; + action_connectors_errors?: Array; + action_connectors_warnings?: WarningSchema[]; } export interface ExportDocumentsProps { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index f02075809a46d..a26517e447d7a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -108,6 +108,7 @@ const RulesPageComponent: React.FC = () => { title={i18n.IMPORT_RULE} showExceptionsCheckBox showCheckBox + showActionConnectorsCheckBox /> diff --git a/x-pack/plugins/security_solution/server/client/client.test.ts b/x-pack/plugins/security_solution/server/client/client.test.ts index a43fbf241aa31..4c0c4361b3a72 100644 --- a/x-pack/plugins/security_solution/server/client/client.test.ts +++ b/x-pack/plugins/security_solution/server/client/client.test.ts @@ -17,7 +17,7 @@ describe('SiemClient', () => { [SIGNALS_INDEX_KEY]: 'mockSignalsIndex', }; const spaceId = 'fooSpace'; - const client = new AppClient(spaceId, mockConfig); + const client = new AppClient(spaceId, mockConfig, '8.7', 'main'); expect(client.getSignalsIndex()).toEqual('mockSignalsIndex-fooSpace'); }); diff --git a/x-pack/plugins/security_solution/server/client/client.ts b/x-pack/plugins/security_solution/server/client/client.ts index 7b6e81ba9693f..03d7afa4b39bd 100644 --- a/x-pack/plugins/security_solution/server/client/client.ts +++ b/x-pack/plugins/security_solution/server/client/client.ts @@ -13,18 +13,24 @@ export class AppClient { private readonly spaceId: string; private readonly previewIndex: string; private readonly sourcererDataViewId: string; + private readonly kibanaVersion: string; + private readonly kibanaBranch: string; - constructor(_spaceId: string, private config: ConfigType) { - const configuredSignalsIndex = this.config.signalsIndex; + constructor(spaceId: string, config: ConfigType, kibanaVersion: string, kibanaBranch: string) { + const configuredSignalsIndex = config.signalsIndex; - this.signalsIndex = `${configuredSignalsIndex}-${_spaceId}`; - this.previewIndex = `${DEFAULT_PREVIEW_INDEX}-${_spaceId}`; - this.sourcererDataViewId = `${DEFAULT_DATA_VIEW_ID}-${_spaceId}`; - this.spaceId = _spaceId; + this.signalsIndex = `${configuredSignalsIndex}-${spaceId}`; + this.previewIndex = `${DEFAULT_PREVIEW_INDEX}-${spaceId}`; + this.sourcererDataViewId = `${DEFAULT_DATA_VIEW_ID}-${spaceId}`; + this.spaceId = spaceId; + this.kibanaVersion = kibanaVersion; + this.kibanaBranch = kibanaBranch; } public getSignalsIndex = (): string => this.signalsIndex; public getPreviewIndex = (): string => this.previewIndex; public getSourcererDataViewId = (): string => this.sourcererDataViewId; public getSpaceId = (): string => this.spaceId; + public getKibanaVersion = (): string => this.kibanaVersion; + public getKibanaBranch = (): string => this.kibanaBranch; } diff --git a/x-pack/plugins/security_solution/server/client/factory.test.ts b/x-pack/plugins/security_solution/server/client/factory.test.ts index f424b5c6901a1..681daf88fc0d9 100644 --- a/x-pack/plugins/security_solution/server/client/factory.test.ts +++ b/x-pack/plugins/security_solution/server/client/factory.test.ts @@ -18,19 +18,39 @@ describe('AppClientFactory', () => { it('constructs a client with the current spaceId', () => { const factory = new AppClientFactory(); const mockRequest = httpServerMock.createKibanaRequest(); - factory.setup({ getSpaceId: () => 'mockSpace', config: createMockConfig() }); + factory.setup({ + getSpaceId: () => 'mockSpace', + config: createMockConfig(), + kibanaVersion: '8.7', + kibanaBranch: 'main', + }); factory.create(mockRequest); - expect(mockClient).toHaveBeenCalledWith('mockSpace', expect.anything()); + expect(mockClient).toHaveBeenCalledWith( + 'mockSpace', + expect.anything(), + expect.anything(), + expect.anything() + ); }); it('constructs a client with the default spaceId if spaces are disabled', () => { const factory = new AppClientFactory(); const mockRequest = httpServerMock.createKibanaRequest(); - factory.setup({ getSpaceId: undefined, config: createMockConfig() }); + factory.setup({ + getSpaceId: undefined, + config: createMockConfig(), + kibanaVersion: '8.7', + kibanaBranch: 'main', + }); factory.create(mockRequest); - expect(mockClient).toHaveBeenCalledWith('default', expect.anything()); + expect(mockClient).toHaveBeenCalledWith( + 'default', + expect.anything(), + expect.anything(), + expect.anything() + ); }); it('cannot call create without calling setup first', () => { diff --git a/x-pack/plugins/security_solution/server/client/factory.ts b/x-pack/plugins/security_solution/server/client/factory.ts index 88dd8a87b19c8..3dc8925c0c6e3 100644 --- a/x-pack/plugins/security_solution/server/client/factory.ts +++ b/x-pack/plugins/security_solution/server/client/factory.ts @@ -8,29 +8,43 @@ import type { KibanaRequest } from '@kbn/core/server'; import { AppClient } from './client'; import type { ConfigType } from '../config'; +import { invariant } from '../../common/utils/invariant'; interface SetupDependencies { getSpaceId?: (request: KibanaRequest) => string | undefined; config: ConfigType; + kibanaVersion: string; + kibanaBranch: string; } export class AppClientFactory { private getSpaceId?: SetupDependencies['getSpaceId']; private config?: SetupDependencies['config']; + private kibanaVersion?: string; + private kibanaBranch?: string; - public setup({ getSpaceId, config }: SetupDependencies) { + public setup({ getSpaceId, config, kibanaBranch, kibanaVersion }: SetupDependencies) { this.getSpaceId = getSpaceId; this.config = config; + this.kibanaVersion = kibanaVersion; + this.kibanaBranch = kibanaBranch; } public create(request: KibanaRequest): AppClient { - if (this.config == null) { - throw new Error( - 'Cannot create AppClient as config is not present. Did you forget to call setup()?' - ); - } + invariant( + this.config != null, + 'Cannot create AppClient as config is not present. Did you forget to call setup()?' + ); + invariant( + this.kibanaVersion != null, + 'Cannot create AppClient as kibanaVersion is not present. Did you forget to call setup()?' + ); + invariant( + this.kibanaBranch != null, + 'Cannot create AppClient as kibanaBranch is not present. Did you forget to call setup()?' + ); const spaceId = this.getSpaceId?.(request) ?? 'default'; - return new AppClient(spaceId, this.config); + return new AppClient(spaceId, this.config, this.kibanaVersion, this.kibanaBranch); } } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_package.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_package.ts new file mode 100644 index 0000000000000..4bbc4c3561674 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_package.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SecuritySolutionApiRequestHandlerContext } from '../../../../../types'; +import { PREBUILT_RULES_PACKAGE_NAME } from '../../../../../../common/detection_engine/constants'; +import type { ConfigType } from '../../../../../config'; + +/** + * Installs the prebuilt rules package of the config's specified or latest version. + * + * @param config Kibana config + * @param context Request handler context + */ +export async function installPrebuiltRulesPackage( + config: ConfigType, + context: SecuritySolutionApiRequestHandlerContext +) { + // Get package version from the config + let pkgVersion = config.prebuiltRulesPackageVersion; + + // Find latest package if the version isn't specified in the config + if (!pkgVersion) { + // Use prerelease versions in dev environment + const isPrerelease = + context.getAppClient().getKibanaVersion().includes('-SNAPSHOT') || + context.getAppClient().getKibanaBranch() === 'main'; + + const result = await context + .getInternalFleetServices() + .packages.fetchFindLatestPackage(PREBUILT_RULES_PACKAGE_NAME, { prerelease: isPrerelease }); + pkgVersion = result.version; + } + + // Install the package + await context + .getInternalFleetServices() + .packages.ensureInstalledPackage({ pkgName: PREBUILT_RULES_PACKAGE_NAME, pkgVersion }); +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/route.ts index d2aa7216404d2..e3f8bdff812c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/route.ts @@ -32,6 +32,7 @@ import { rulesToMap } from '../../logic/utils'; import { installPrepackagedTimelines } from '../../../../timeline/routes/prepackaged_timelines/install_prepackaged_timelines'; import { buildSiemResponse } from '../../../routes/utils'; +import { installPrebuiltRulesPackage } from './install_prebuilt_rules_package'; export const installPrebuiltRulesAndTimelinesRoute = (router: SecuritySolutionPluginRouter) => { router.put( @@ -103,7 +104,14 @@ export const createPrepackagedRules = async ( await exceptionsListClient.createEndpointList(); } - const latestPrepackagedRulesMap = await getLatestPrebuiltRules(ruleAssetsClient); + let latestPrepackagedRulesMap = await getLatestPrebuiltRules(ruleAssetsClient); + if (latestPrepackagedRulesMap.size === 0) { + // Seems no packages with prepackaged rules were installed, try to install the default rules package + await installPrebuiltRulesPackage(config, context); + + // Try to get the prepackaged rules again + latestPrepackagedRulesMap = await getLatestPrebuiltRules(ruleAssetsClient); + } const installedPrePackagedRules = rulesToMap(await getExistingPrepackagedRules({ rulesClient })); const rulesToInstall = getRulesToInstall(latestPrepackagedRulesMap, installedPrePackagedRules); const rulesToUpdate = getRulesToUpdate(latestPrepackagedRulesMap, installedPrePackagedRules); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 2234f09cb1888..e3fabc5366980 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -326,12 +326,18 @@ export const performBulkActionRoute = ( 'alerting', 'licensing', 'lists', + 'actions', ]); const rulesClient = ctx.alerting.getRulesClient(); const exceptionsClient = ctx.lists?.getExceptionListClient(); const savedObjectsClient = ctx.core.savedObjects.client; + const { getExporter, getClient } = (await ctx.core).savedObjects; + const client = getClient({ includedHiddenTypes: ['action'] }); + + const exporter = getExporter(client); + const mlAuthz = buildMlAuthz({ license: ctx.licensing.license, ml, @@ -556,10 +562,12 @@ export const performBulkActionRoute = ( exceptionsClient, savedObjectsClient, rules.map(({ params }) => ({ rule_id: params.ruleId })), - logger + logger, + exporter, + request ); - const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.exportDetails}`; + const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.actionConnectors}${exported.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts index ec96f38f5f86e..a1a5f798fc002 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts @@ -45,8 +45,14 @@ export const exportRulesRoute = ( const siemResponse = buildSiemResponse(response); const rulesClient = (await context.alerting).getRulesClient(); const exceptionsClient = (await context.lists)?.getExceptionListClient(); - const savedObjectsClient = (await context.core).savedObjects.client; + const { + getExporter, + getClient, + client: savedObjectsClient, + } = (await context.core).savedObjects; + const client = getClient({ includedHiddenTypes: ['action'] }); + const actionsExporter = getExporter(client); try { const exportSizeLimit = config.maxRuleImportExportSize; if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { @@ -66,20 +72,29 @@ export const exportRulesRoute = ( } } - const exportedRulesAndExceptions = + const exportedRulesAndReferences = request.body?.objects != null ? await getExportByObjectIds( rulesClient, exceptionsClient, savedObjectsClient, request.body.objects, - logger + logger, + actionsExporter, + request ) - : await getExportAll(rulesClient, exceptionsClient, savedObjectsClient, logger); + : await getExportAll( + rulesClient, + exceptionsClient, + savedObjectsClient, + logger, + actionsExporter, + request + ); const responseBody = request.query.exclude_export_details - ? exportedRulesAndExceptions.rulesNdjson - : `${exportedRulesAndExceptions.rulesNdjson}${exportedRulesAndExceptions.exceptionLists}${exportedRulesAndExceptions.exportDetails}`; + ? exportedRulesAndReferences.rulesNdjson + : `${exportedRulesAndReferences.rulesNdjson}${exportedRulesAndReferences.exceptionLists}${exportedRulesAndReferences.actionConnectors}${exportedRulesAndReferences.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts index 3d466d2505074..bb1bb7d55244a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.test.ts @@ -107,6 +107,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -146,6 +150,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -171,6 +179,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -196,6 +208,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -218,6 +234,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); @@ -242,6 +262,10 @@ describe('Import rules route', () => { rules_count: 2, exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -262,6 +286,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -302,6 +330,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -332,6 +364,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -353,6 +389,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); @@ -387,6 +427,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); @@ -407,6 +451,10 @@ describe('Import rules route', () => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [], }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts index e306caac12194..ddcbf2cbdd6e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts @@ -16,7 +16,6 @@ import type { ImportQuerySchemaDecoded } from '@kbn/securitysolution-io-ts-types import { importQuerySchema } from '@kbn/securitysolution-io-ts-types'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../../../common/constants'; -import type { RuleToImport } from '../../../../../../../common/detection_engine/rule_management'; import { ImportRulesResponse } from '../../../../../../../common/detection_engine/rule_management'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; @@ -28,7 +27,6 @@ import { isBulkError, isImportRegular, buildSiemResponse } from '../../../../rou import { getTupleDuplicateErrorsAndUniqueRules, - getInvalidConnectors, migrateLegacyActionsIds, } from '../../../utils/utils'; import { createRulesAndExceptionsStreamFromNdJson } from '../../../logic/import/create_rules_stream_from_ndjson'; @@ -38,6 +36,7 @@ import { importRules as importRulesHelper } from '../../../logic/import/import_r import { getReferencedExceptionLists } from '../../../logic/import/gather_referenced_exceptions'; import { importRuleExceptions } from '../../../logic/import/import_rule_exceptions'; import type { HapiReadableStream } from '../../../logic/import/hapi_readable_stream'; +import { importRuleActionConnectors } from '../../../logic/import/action_connectors/import_rule_action_connectors'; const CHUNK_PARSED_OBJECT_SIZE = 50; @@ -81,6 +80,8 @@ export const importRulesRoute = ( const actionSOClient = ctx.core.savedObjects.getClient({ includedHiddenTypes: ['action'], }); + const actionsImporter = ctx.core.savedObjects.getImporter(actionSOClient); + const savedObjectsClient = ctx.core.savedObjects.client; const exceptionsClient = ctx.lists?.getExceptionListClient(); @@ -104,7 +105,7 @@ export const importRulesRoute = ( // parse file to separate out exceptions from rules const readAllStream = createRulesAndExceptionsStreamFromNdJson(objectLimit); - const [{ exceptions, rules }] = await createPromiseFromStreams< + const [{ exceptions, rules, actionConnectors }] = await createPromiseFromStreams< RuleExceptionsPromiseFromStreams[] >([request.body.file as HapiReadableStream, ...readAllStream]); @@ -119,7 +120,6 @@ export const importRulesRoute = ( overwrite: request.query.overwrite_exceptions, maxExceptionsImportSize: objectLimit, }); - // report on duplicate rules const [duplicateIdErrors, parsedObjectsWithoutDuplicateErrors] = getTupleDuplicateErrorsAndUniqueRules(rules, request.query.overwrite); @@ -129,20 +129,23 @@ export const importRulesRoute = ( actionSOClient ); - let parsedRules; - let actionErrors: BulkError[] = []; - const actualRules = rules.filter((rule): rule is RuleToImport => !(rule instanceof Error)); - - if (actualRules.some((rule) => rule.actions && rule.actions.length > 0)) { - const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors( - migratedParsedObjectsWithoutDuplicateErrors, - actionsClient - ); - parsedRules = uniqueParsedObjects; - actionErrors = nonExistentActionErrors; - } else { - parsedRules = migratedParsedObjectsWithoutDuplicateErrors; - } + // import actions-connectors + const { + successCount: actionConnectorSuccessCount, + success: actionConnectorSuccess, + warnings: actionConnectorWarnings, + errors: actionConnectorErrors, + } = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter, + rules: migratedParsedObjectsWithoutDuplicateErrors, + overwrite: request.query.overwrite_action_connectors, + }); + const parsedRules = actionConnectorErrors.length + ? [] + : migratedParsedObjectsWithoutDuplicateErrors; + // gather all exception lists that the imported rules reference const foundReferencedExceptionLists = await getReferencedExceptionLists({ rules: parsedRules, @@ -153,7 +156,7 @@ export const importRulesRoute = ( const importRuleResponse: ImportRuleResponse[] = await importRulesHelper({ ruleChunks: chunkParseObjects, - rulesResponseAcc: [...actionErrors, ...duplicateIdErrors], + rulesResponseAcc: [...actionConnectorErrors, ...duplicateIdErrors], mlAuthz, overwriteRules: request.query.overwrite, rulesClient, @@ -161,6 +164,7 @@ export const importRulesRoute = ( exceptionsClient, spaceId: ctx.securitySolution.getSpaceId(), existingLists: foundReferencedExceptionLists, + allowMissingConnectorSecrets: !!actionConnectors.length, }); const errorsResp = importRuleResponse.filter((resp) => isBulkError(resp)) as BulkError[]; @@ -179,6 +183,10 @@ export const importRulesRoute = ( exceptions_errors: exceptionsErrors, exceptions_success: exceptionsSuccess, exceptions_success_count: exceptionsSuccessCount, + action_connectors_success: actionConnectorSuccess, + action_connectors_success_count: actionConnectorSuccessCount, + action_connectors_errors: actionConnectorErrors, + action_connectors_warnings: actionConnectorWarnings, }; const [validated, errors] = validate(importRules, ImportRulesResponse); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts index f621542fe433f..416edb039919e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/create_rules.ts @@ -19,6 +19,7 @@ export interface CreateRulesOptions id?: string; immutable?: boolean; defaultEnabled?: boolean; + allowMissingConnectorSecrets?: boolean; } export const createRules = async ({ @@ -27,6 +28,7 @@ export const createRules = async ({ id, immutable = false, defaultEnabled = true, + allowMissingConnectorSecrets, }: CreateRulesOptions): Promise> => { const internalRule = convertCreateAPIToInternalSchema(params, immutable, defaultEnabled); const rule = await rulesClient.create({ @@ -34,6 +36,7 @@ export const createRules = async ({ id, }, data: internalRule, + allowMissingConnectorSecrets, }); // Mute the rule if it is first created with the explicit no actions diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts index 656366ec9f92b..45d1a21325abd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/crud/patch_rules.ts @@ -16,12 +16,14 @@ export interface PatchRulesOptions { rulesClient: RulesClient; nextParams: PatchRuleRequestBody; existingRule: RuleAlertType | null | undefined; + allowMissingConnectorSecrets?: boolean; } export const patchRules = async ({ rulesClient, existingRule, nextParams, + allowMissingConnectorSecrets, }: PatchRulesOptions): Promise | null> => { if (existingRule == null) { return null; @@ -32,6 +34,7 @@ export const patchRules = async ({ const update = await rulesClient.update({ id: existingRule.id, data: patchedRule, + allowMissingConnectorSecrets, }); if (nextParams.throttle !== undefined) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts index b7395236a2152..d5511ccf7b1cc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.test.ts @@ -24,13 +24,17 @@ import { getQueryRuleParams } from '../../../rule_schema/mocks'; import { getExceptionListClientMock } from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client.mock'; import type { loggingSystemMock } from '@kbn/core/server/mocks'; import { requestContextMock } from '../../../routes/__mocks__/request_context'; +import { savedObjectsExporterMock } from '@kbn/core-saved-objects-import-export-server-mocks'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; +import { Readable } from 'stream'; const exceptionsClient = getExceptionListClientMock(); describe('getExportAll', () => { let logger: ReturnType; const { clients } = requestContextMock.createTools(); - + const exporterMock = savedObjectsExporterMock.create(); + const requestMock = mockRouter.createKibanaRequest(); beforeEach(async () => { clients.savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectsResponse()); }); @@ -55,7 +59,9 @@ describe('getExportAll', () => { rulesClient, exceptionsClient, clients.savedObjectsClient, - logger + logger, + exporterMock, + requestMock ); const rulesJson = JSON.parse(exports.rulesNdjson); const detailsJson = JSON.parse(exports.exportDetails); @@ -114,6 +120,11 @@ describe('getExportAll', () => { missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], }); }); @@ -133,12 +144,186 @@ describe('getExportAll', () => { rulesClient, exceptionsClient, clients.savedObjectsClient, - logger + logger, + exporterMock, + requestMock ); expect(exports).toEqual({ rulesNdjson: '', exportDetails: getSampleDetailsAsNdjson(details), exceptionLists: '', + actionConnectors: '', + }); + }); + test('it will export with rule and action connectors', async () => { + const rulesClient = rulesClientMock.create(); + const result = getFindResultWithSingleHit(); + const alert = { + ...getRuleMock(getQueryRuleParams()), + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + actionTypeId: '.slack', + }, + ], + }; + + alert.params = { + ...alert.params, + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + threat: getThreatMock(), + meta: { someMeta: 'someField' }, + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', + }; + result.data = [alert]; + rulesClient.find.mockResolvedValue(result); + let eventCount = 0; + const readable = new Readable({ + objectMode: true, + read() { + if (eventCount === 0) { + eventCount += 1; + return this.push({ + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + created_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', + attributes: { + actionTypeId: '.slack', + name: 'slack', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }); + } + if (eventCount === 1) { + eventCount += 1; + return this.push({ + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + excludedObjectsCount: 0, + excludedObjects: [], + }); + } + return this.push(null); + }, + }); + + const exporterMockWithConnector = { + exportByObjects: () => jest.fn().mockReturnValueOnce(readable), + + exportByTypes: jest.fn(), + }; + const exports = await getExportAll( + rulesClient, + exceptionsClient, + clients.savedObjectsClient, + logger, + exporterMockWithConnector as never, + requestMock + ); + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + const actionConnectorsJSON = JSON.parse(exports.actionConnectors); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + building_block_type: 'default', + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + license: 'Elastic License', + output_index: '.siem-signals', + max_signals: 10000, + risk_score: 50, + risk_score_mapping: [], + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + severity_mapping: [], + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: getThreatMock(), + throttle: 'rule', + note: '# Investigative notes', + version: 1, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).toEqual({ + exported_exception_list_count: 1, + exported_exception_list_item_count: 1, + exported_count: 4, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + expect(actionConnectorsJSON).toEqual({ + attributes: { + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + created_at: '2023-01-11T11:30:31.683Z', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts index 058559de7db59..88f3e9019d493 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_all.ts @@ -7,13 +7,14 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import type { Logger } from '@kbn/core/server'; +import type { ISavedObjectsExporter, KibanaRequest, Logger } from '@kbn/core/server'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import type { RulesClient, RuleExecutorServices } from '@kbn/alerting-plugin/server'; import { getNonPackagedRules } from '../search/get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../../utils/utils'; import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; +import { getRuleActionConnectorsForExport } from './get_export_rule_action_connectors'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../../../rule_actions_legacy'; @@ -22,11 +23,14 @@ export const getExportAll = async ( rulesClient: RulesClient, exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: RuleExecutorServices['savedObjectsClient'], - logger: Logger + logger: Logger, + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; + actionConnectors: string; }> => { const ruleAlertTypes = await getNonPackagedRules({ rulesClient }); const alertIds = ruleAlertTypes.map((rule) => rule.id); @@ -44,7 +48,15 @@ export const getExportAll = async ( const { exportData: exceptionLists, exportDetails: exceptionDetails } = await getRuleExceptionsForExport(exceptions, exceptionsClient); + // Retrieve Action-Connectors + const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport( + rules, + actionsExporter, + request + ); + const rulesNdjson = transformDataToNdjson(rules); - const exportDetails = getExportDetailsNdjson(rules, [], exceptionDetails); - return { rulesNdjson, exportDetails, exceptionLists }; + const exportDetails = getExportDetailsNdjson(rules, [], exceptionDetails, actionConnectorDetails); + + return { rulesNdjson, exportDetails, exceptionLists, actionConnectors }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts index 02b83342cd846..570954ba9c8b0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Readable } from 'stream'; import type { RulesErrors } from './get_export_by_object_ids'; import { getExportByObjectIds, getRulesFromObjects } from './get_export_by_object_ids'; import type { FindHit } from '../../../routes/__mocks__/request_responses'; @@ -22,6 +22,8 @@ import { } from '../../../../../../common/detection_engine/rule_management/mocks'; import { getQueryRuleParams } from '../../../rule_schema/mocks'; import { getExceptionListClientMock } from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client.mock'; +import { savedObjectsExporterMock } from '@kbn/core-saved-objects-import-export-server-mocks'; +import { mockRouter } from '@kbn/core-http-router-server-mocks'; const exceptionsClient = getExceptionListClientMock(); import type { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -30,6 +32,8 @@ import { requestContextMock } from '../../../routes/__mocks__/request_context'; describe('get_export_by_object_ids', () => { let logger: ReturnType; const { clients } = requestContextMock.createTools(); + const exporterMock = savedObjectsExporterMock.create(); + const requestMock = mockRouter.createKibanaRequest(); beforeEach(() => { jest.resetAllMocks(); @@ -50,7 +54,9 @@ describe('get_export_by_object_ids', () => { exceptionsClient, clients.savedObjectsClient, objects, - logger + logger, + exporterMock, + requestMock ); const exportsObj = { rulesNdjson: JSON.parse(exports.rulesNdjson), @@ -112,6 +118,11 @@ describe('get_export_by_object_ids', () => { missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], }, }); }); @@ -137,7 +148,9 @@ describe('get_export_by_object_ids', () => { exceptionsClient, clients.savedObjectsClient, objects, - logger + logger, + exporterMock, + requestMock ); const details = getOutputDetailsSampleWithExceptions({ missingRules: [{ rule_id: 'rule-1' }], @@ -147,6 +160,180 @@ describe('get_export_by_object_ids', () => { rulesNdjson: '', exportDetails: getSampleDetailsAsNdjson(details), exceptionLists: '', + actionConnectors: '', + }); + }); + + test('it will export with rule and action connectors', async () => { + const rulesClient = rulesClientMock.create(); + const result = getFindResultWithSingleHit(); + const alert = { + ...getRuleMock(getQueryRuleParams()), + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + actionTypeId: '.slack', + }, + ], + }; + + alert.params = { + ...alert.params, + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + threat: getThreatMock(), + meta: { someMeta: 'someField' }, + timelineId: 'some-timeline-id', + timelineTitle: 'some-timeline-title', + }; + result.data = [alert]; + rulesClient.find.mockResolvedValue(result); + let eventCount = 0; + const readable = new Readable({ + objectMode: true, + read() { + if (eventCount === 0) { + eventCount += 1; + return this.push({ + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + created_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', + attributes: { + actionTypeId: '.slack', + name: 'slack', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }); + } + if (eventCount === 1) { + eventCount += 1; + return this.push({ + exportedCount: 1, + missingRefCount: 0, + missingReferences: [], + excludedObjectsCount: 0, + excludedObjects: [], + }); + } + return this.push(null); + }, + }); + const objects = [{ rule_id: 'rule-1' }]; + const exporterMockWithConnector = { + exportByObjects: () => jest.fn().mockReturnValueOnce(readable), + + exportByTypes: jest.fn(), + }; + const exports = await getExportByObjectIds( + rulesClient, + exceptionsClient, + clients.savedObjectsClient, + objects, + logger, + exporterMockWithConnector as never, + requestMock + ); + const rulesJson = JSON.parse(exports.rulesNdjson); + const detailsJson = JSON.parse(exports.exportDetails); + const actionConnectorsJSON = JSON.parse(exports.actionConnectors); + expect(rulesJson).toEqual({ + author: ['Elastic'], + actions: [ + { + group: 'default', + id: '123', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + building_block_type: 'default', + created_at: '2019-12-13T16:40:33.400Z', + updated_at: '2019-12-13T16:40:33.400Z', + created_by: 'elastic', + description: 'Detecting root and admin users', + enabled: true, + false_positives: [], + filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }], + from: 'now-6m', + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + immutable: false, + index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], + interval: '5m', + rule_id: 'rule-1', + language: 'kuery', + license: 'Elastic License', + output_index: '.siem-signals', + max_signals: 10000, + risk_score: 50, + risk_score_mapping: [], + name: 'Detect Root/Admin Users', + query: 'user.name: root or user.name: admin', + references: ['http://example.com', 'https://example.com'], + related_integrations: [], + required_fields: [], + setup: '', + timeline_id: 'some-timeline-id', + timeline_title: 'some-timeline-title', + meta: { someMeta: 'someField' }, + severity: 'high', + severity_mapping: [], + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'query', + threat: getThreatMock(), + throttle: 'rule', + note: '# Investigative notes', + version: 1, + exceptions_list: getListArrayMock(), + }); + expect(detailsJson).toEqual({ + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_count: 2, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + expect(actionConnectorsJSON).toEqual({ + attributes: { + actionTypeId: '.slack', + config: {}, + isMissingSecrets: true, + name: 'slack', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + created_at: '2023-01-11T11:30:31.683Z', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + updated_at: '2023-01-11T11:30:31.683Z', + version: 'WzE2MDYsMV0=', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts index e15ab98e0d0ca..90938420e64b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_by_object_ids.ts @@ -6,9 +6,10 @@ */ import { chunk } from 'lodash'; + import { transformDataToNdjson } from '@kbn/securitysolution-utils'; -import type { Logger } from '@kbn/core/server'; +import type { ISavedObjectsExporter, KibanaRequest, Logger } from '@kbn/core/server'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import type { RulesClient, RuleExecutorServices } from '@kbn/alerting-plugin/server'; @@ -17,6 +18,7 @@ import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../../../rule_schema'; import { findRules } from '../search/find_rules'; import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; +import { getRuleActionConnectorsForExport } from './get_export_rule_action_connectors'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../../../rule_actions_legacy'; @@ -44,11 +46,14 @@ export const getExportByObjectIds = async ( exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: RuleExecutorServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, - logger: Logger + logger: Logger, + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest ): Promise<{ rulesNdjson: string; exportDetails: string; exceptionLists: string | null; + actionConnectors: string; }> => { const rulesAndErrors = await getRulesFromObjects( rulesClient, @@ -56,23 +61,33 @@ export const getExportByObjectIds = async ( objects, logger ); + const { rules, missingRules } = rulesAndErrors; // Retrieve exceptions - const exceptions = rulesAndErrors.rules.flatMap((rule) => rule.exceptions_list ?? []); + const exceptions = rules.flatMap((rule) => rule.exceptions_list ?? []); const { exportData: exceptionLists, exportDetails: exceptionDetails } = await getRuleExceptionsForExport(exceptions, exceptionsClient); - const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); + // Retrieve Action-Connectors + const { actionConnectors, actionConnectorDetails } = await getRuleActionConnectorsForExport( + rules, + actionsExporter, + request + ); + + const rulesNdjson = transformDataToNdjson(rules); const exportDetails = getExportDetailsNdjson( - rulesAndErrors.rules, - rulesAndErrors.missingRules, - exceptionDetails + rules, + missingRules, + exceptionDetails, + actionConnectorDetails ); return { rulesNdjson, exportDetails, exceptionLists, + actionConnectors, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts index 465c4b53b1e51..9efbe665812c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_details_ndjson.ts @@ -8,23 +8,29 @@ import type { ExportExceptionDetails } from '@kbn/securitysolution-io-ts-list-types'; import type { ExportRulesDetails } from '../../../../../../common/detection_engine/rule_management'; import type { RuleResponse } from '../../../../../../common/detection_engine/rule_schema'; +import type { DefaultActionConnectorDetails } from './get_export_rule_action_connectors'; export const getExportDetailsNdjson = ( rules: RuleResponse[], missingRules: Array<{ rule_id: string }> = [], - exceptionDetails?: ExportExceptionDetails + exceptionDetails?: ExportExceptionDetails, + actionConnectorDetails?: DefaultActionConnectorDetails ): string => { + let exportedCount = rules.length; + if (actionConnectorDetails != null) + exportedCount += actionConnectorDetails.exported_action_connector_count; + if (exceptionDetails != null) + exportedCount += + exceptionDetails.exported_exception_list_count + + exceptionDetails.exported_exception_list_item_count; + const stringified: ExportRulesDetails = { - exported_count: - exceptionDetails == null - ? rules.length - : rules.length + - exceptionDetails.exported_exception_list_count + - exceptionDetails.exported_exception_list_item_count, + exported_count: exportedCount, exported_rules_count: rules.length, missing_rules: missingRules, missing_rules_count: missingRules.length, ...exceptionDetails, + ...actionConnectorDetails, }; return `${JSON.stringify(stringified)}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts new file mode 100644 index 0000000000000..04472ff6b4598 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/export/get_export_rule_action_connectors.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common'; +import type { + SavedObjectsExportResultDetails, + ISavedObjectsExporter, + SavedObjectsExportExcludedObject, + SavedObject, +} from '@kbn/core-saved-objects-server'; +import { createConcatStream, createMapStream, createPromiseFromStreams } from '@kbn/utils'; +import type { RuleResponse } from '../../../../../../common/detection_engine/rule_schema'; + +export interface DefaultActionConnectorDetails { + exported_action_connector_count: number; + missing_action_connection_count: number; + missing_action_connections: SavedObjectTypeIdTuple[]; + excluded_action_connection_count: number; + excluded_action_connections: SavedObjectsExportExcludedObject[]; +} + +const defaultActionConnectorDetails: DefaultActionConnectorDetails = { + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + excluded_action_connection_count: 0, + excluded_action_connections: [], +}; + +const mapExportedActionConnectorsDetailsToDefault = ( + exportDetails: SavedObjectsExportResultDetails +): DefaultActionConnectorDetails => { + return { + exported_action_connector_count: exportDetails.exportedCount, + missing_action_connection_count: exportDetails.missingRefCount, + missing_action_connections: exportDetails.missingReferences, + excluded_action_connection_count: exportDetails.excludedObjectsCount, + excluded_action_connections: exportDetails.excludedObjects, + }; +}; + +export const getRuleActionConnectorsForExport = async ( + rules: RuleResponse[], + actionsExporter: ISavedObjectsExporter, + request: KibanaRequest +) => { + const exportedActionConnectors: { + actionConnectors: string; + actionConnectorDetails: DefaultActionConnectorDetails; + } = { + actionConnectors: '', + actionConnectorDetails: defaultActionConnectorDetails, + }; + + const actionsIds = [...new Set(rules.flatMap((rule) => rule.actions.map(({ id }) => id)))]; + + if (!actionsIds.length) return exportedActionConnectors; + + const getActionsByObjectsParam = actionsIds.map((id) => ({ type: 'action', id })); + const actionDetails = await actionsExporter.exportByObjects({ + objects: getActionsByObjectsParam, + request, + }); + + if (!actionDetails) { + exportedActionConnectors.actionConnectorDetails = { + exported_action_connector_count: 0, + missing_action_connection_count: actionsIds.length, + missing_action_connections: [], // TODO: check how to generate SO + excluded_action_connection_count: 0, + excluded_action_connections: [], + }; + return exportedActionConnectors; + } + + const actionsConnectorsToExport: SavedObject[] = await createPromiseFromStreams([ + actionDetails, + createMapStream((obj: SavedObject | SavedObjectsExportResultDetails) => { + if ('exportedCount' in obj) + exportedActionConnectors.actionConnectorDetails = + mapExportedActionConnectorsDetailsToDefault(obj); + else return JSON.stringify(obj); + }), + createConcatStream([]), + ]); + exportedActionConnectors.actionConnectors = actionsConnectorsToExport.join('\n'); + return exportedActionConnectors; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts new file mode 100644 index 0000000000000..455274006297e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.test.ts @@ -0,0 +1,383 @@ +/* + * Copyright 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 { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock'; +import { + getImportRulesSchemaMock, + webHookConnector, +} from '../../../../../../../common/detection_engine/rule_management/model/import/rule_to_import.mock'; +import { importRuleActionConnectors } from './import_rule_action_connectors'; +import { coreMock } from '@kbn/core/server/mocks'; + +const rules = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, +]; +const rulesWithoutActions = [ + { + ...getImportRulesSchemaMock(), + actions: [], + }, +]; +const actionConnectors = [webHookConnector]; +const actionsClient = actionsClientMock.create(); +actionsClient.getAll.mockResolvedValue([]); +const core = coreMock.createRequestHandlerContext(); + +describe('checkRuleExceptionReferences', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should show an error message when the user has a Read Actions permission and stops the importing ', async () => { + const newCore = coreMock.createRequestHandlerContext(); + const error = { + output: { payload: { message: 'Unable to bulk_create action' }, statusCode: 403 }, + }; + newCore.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockImplementation(() => { + throw error; + }), + }); + const actionsImporter2 = newCore.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + warnings: [], + }); + }); + + it('should return import 1 connector successfully', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }); + }); + it('should return import 1 connector successfully only if id is duplicated', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + const ruleWith2Connectors = [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + params: { + message: 'Rule {{context.rule.name}} generated {{state.signals_count}} alerts', + }, + action_type_id: '.slack', + }, + ], + }, + ]; + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules: ruleWith2Connectors, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }); + }); + + it('should show an error message when the user has an old imported rule with a missing connector data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aaf1', + status_code: 404, + }, + rule_id: 'rule-1', + }, + ], + warnings: [], + }); + }); + it('should show an error message when the user has an old imported rule with a 2 missing connectors data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules: [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf2', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + ], + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '2 connectors are missing. Connector ids missing are: cabc78e0-9031-11ed-b076-53cc4d57aaf1, cabc78e0-9031-11ed-b076-53cc4d57aaf2', + status_code: 404, + }, + rule_id: 'rule-1', + }, + ], + warnings: [], + }); + }); + it('should show an error message when the user has 2 imported rules with a 2 missing connectors data', async () => { + const actionsImporter = core.savedObjects.getImporter; + + const res = await importRuleActionConnectors({ + actionConnectors: [], + actionsClient, + actionsImporter: actionsImporter() as never, + rules: [ + { + ...getImportRulesSchemaMock(), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + { + ...getImportRulesSchemaMock('rule-2'), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf2', + action_type_id: '.webhook', + params: {}, + }, + ], + }, + ], + overwrite: false, + }); + + expect(res).toEqual({ + success: false, + successCount: 0, + errors: [ + { + error: { + message: + '2 connectors are missing. Connector ids missing are: cabc78e0-9031-11ed-b076-53cc4d57aaf1, cabc78e0-9031-11ed-b076-53cc4d57aaf2', + status_code: 404, + }, + rule_id: 'rule-1,rule-2', + }, + ], + warnings: [], + }); + }); + + it('should skip importing the action-connectors if the actions array is empty, even if the user has exported-connectors in the file', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValue({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 2, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter2 = core.savedObjects.getImporter; + const actionsImporter2Import = actionsImporter2().import; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2Import as never, + rules: rulesWithoutActions, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + expect(actionsImporter2Import).not.toBeCalled(); + }); + + it('should skip importing the action-connectors if all connectors have been imported/created before', async () => { + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + referencedByCount: 1, + isDeprecated: false, + }, + ]); + const actionsImporter2 = core.savedObjects.getImporter; + const actionsImporter2Import = actionsImporter2().import; + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter2Import as never, + rules, + overwrite: false, + }); + + expect(res).toEqual({ + success: true, + successCount: 0, + errors: [], + warnings: [], + }); + expect(actionsImporter2Import).not.toBeCalled(); + }); + + it('should not skip importing the action-connectors if all connectors have been imported/created before when overwrite is true', async () => { + core.savedObjects.getImporter = jest.fn().mockReturnValueOnce({ + import: jest.fn().mockResolvedValue({ + success: true, + successCount: 1, + successResults: [], + errors: [], + warnings: [], + }), + }); + const actionsImporter = core.savedObjects.getImporter; + + actionsClient.getAll.mockResolvedValue([ + { + actionTypeId: '.webhook', + name: 'webhook', + isPreconfigured: true, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + referencedByCount: 1, + isDeprecated: false, + }, + ]); + + const res = await importRuleActionConnectors({ + actionConnectors, + actionsClient, + actionsImporter: actionsImporter() as never, + rules, + overwrite: true, + }); + + expect(res).toEqual({ + success: true, + successCount: 1, + errors: [], + warnings: [], + successResults: [], + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts new file mode 100644 index 0000000000000..3d2fc9fb7c6b6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/import_rule_action_connectors.ts @@ -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 { Readable } from 'stream'; + +import type { SavedObjectsImportResponse } from '@kbn/core-saved-objects-common'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; + +import type { WarningSchema } from '../../../../../../../common/detection_engine/schemas/response'; +import { + checkIfActionsHaveMissingConnectors, + filterExistingActionConnectors, + getActionConnectorRules, + handleActionsHaveNoConnectors, + mapSOErrorToRuleError, + returnErroredImportResult, +} from './utils'; +import type { ImportRuleActionConnectorsParams, ImportRuleActionConnectorsResult } from './types'; + +export const importRuleActionConnectors = async ({ + actionConnectors, + actionsClient, + actionsImporter, + rules, + overwrite, +}: ImportRuleActionConnectorsParams): Promise => { + try { + const actionConnectorRules = getActionConnectorRules(rules); + const actionsIds: string[] = Object.keys(actionConnectorRules); + + if (!actionsIds.length) + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; + + if (overwrite && !actionConnectors.length) + return handleActionsHaveNoConnectors(actionsIds, actionConnectorRules); + + let actionConnectorsToImport: SavedObject[] = actionConnectors; + + if (!overwrite) { + const newIdsToAdd = await filterExistingActionConnectors(actionsClient, actionsIds); + + const foundMissingConnectors = checkIfActionsHaveMissingConnectors( + actionConnectors, + newIdsToAdd, + actionConnectorRules + ); + if (foundMissingConnectors) return foundMissingConnectors; + // filter out existing connectors + actionConnectorsToImport = actionConnectors.filter(({ id }) => newIdsToAdd.includes(id)); + } + if (!actionConnectorsToImport.length) + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; + + const readStream = Readable.from(actionConnectorsToImport); + const { success, successCount, successResults, warnings, errors }: SavedObjectsImportResponse = + await actionsImporter.import({ + readStream, + overwrite, + createNewCopies: false, + }); + return { + success, + successCount, + successResults, + errors: errors ? mapSOErrorToRuleError(errors) : [], + warnings: (warnings as WarningSchema[]) || [], + }; + } catch (error) { + return returnErroredImportResult(error); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts new file mode 100644 index 0000000000000..8ba6c41c42dde --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { ISavedObjectsImporter, SavedObject } from '@kbn/core-saved-objects-server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { + SavedObjectsImportFailure, + SavedObjectsImportSuccess, +} from '@kbn/core-saved-objects-common'; +import type { RuleToImport } from '../../../../../../../common/detection_engine/rule_management'; +import type { WarningSchema } from '../../../../../../../common/detection_engine/schemas/response'; +import type { BulkError } from '../../../../routes/utils'; + +export interface ImportRuleActionConnectorsResult { + success: boolean; + successCount: number; + successResults?: SavedObjectsImportSuccess[]; + errors: BulkError[] | []; + warnings: WarningSchema[] | []; +} + +export interface ImportRuleActionConnectorsParams { + actionConnectors: SavedObject[]; + actionsClient: ActionsClient; + actionsImporter: ISavedObjectsImporter; + rules: Array; + overwrite: boolean; +} + +export interface SOError { + output: { statusCode: number; payload: { message: string } }; +} + +export interface ConflictError { + type: string; +} + +export type ErrorType = SOError | ConflictError | SavedObjectsImportFailure | Error; +export interface ActionRules { + [actionsIds: string]: string[]; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts new file mode 100644 index 0000000000000..6d9b3b9da9e6d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/action_connectors/utils/index.ts @@ -0,0 +1,129 @@ +/* + * Copyright 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 { pick } from 'lodash'; +import type { SavedObjectsImportFailure } from '@kbn/core-saved-objects-common'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; +import type { ActionsClient } from '@kbn/actions-plugin/server'; +import type { BulkError } from '../../../../../routes/utils'; +import { createBulkErrorObject } from '../../../../../routes/utils'; +import type { RuleToImport } from '../../../../../../../../common/detection_engine/rule_management'; +import type { + ActionRules, + ConflictError, + ErrorType, + ImportRuleActionConnectorsResult, + SOError, +} from '../types'; + +export const returnErroredImportResult = (error: ErrorType): ImportRuleActionConnectorsResult => ({ + success: false, + errors: [handleActionConnectorsErrors(error)], + successCount: 0, + warnings: [], +}); + +export const handleActionsHaveNoConnectors = ( + actionsIds: string[], + actionConnectorRules: ActionRules +): ImportRuleActionConnectorsResult => { + const ruleIds: string = [...new Set(Object.values(actionConnectorRules).flat())].join(); + + if (actionsIds && actionsIds.length) { + const errors: BulkError[] = []; + const errorMessage = + actionsIds.length > 1 + ? 'connectors are missing. Connector ids missing are:' + : 'connector is missing. Connector id missing is:'; + errors.push( + createBulkErrorObject({ + statusCode: 404, + message: `${actionsIds.length} ${errorMessage} ${actionsIds.join(', ')}`, + ruleId: ruleIds, + }) + ); + return { + success: false, + errors, + successCount: 0, + warnings: [], + }; + } + return { + success: true, + errors: [], + successCount: 0, + warnings: [], + }; +}; + +export const handleActionConnectorsErrors = (error: ErrorType, id?: string): BulkError => { + let statusCode: number | null = null; + let message: string = ''; + if ('output' in error) { + statusCode = (error as SOError).output.statusCode; + message = (error as SOError).output.payload?.message; + } + switch (statusCode) { + case null: + return createBulkErrorObject({ + statusCode: 500, + message: + (error as ConflictError)?.type === 'conflict' + ? 'There is a conflict' + : (error as Error).message + ? (error as Error).message + : '', + }); + + case 403: + return createBulkErrorObject({ + id, + statusCode, + message: `You may not have actions privileges required to import rules with actions: ${message}`, + }); + + default: + return createBulkErrorObject({ + id, + statusCode, + message, + }); + } +}; + +export const mapSOErrorToRuleError = (errors: SavedObjectsImportFailure[]): BulkError[] => { + return errors.map(({ id, error }) => handleActionConnectorsErrors(error, id)); +}; + +export const filterExistingActionConnectors = async ( + actionsClient: ActionsClient, + actionsIds: string[] +) => { + const storedConnectors = await actionsClient.getAll(); + const storedActionIds: string[] = storedConnectors.map(({ id }) => id); + return actionsIds.filter((id) => !storedActionIds.includes(id)); +}; +export const getActionConnectorRules = (rules: Array) => + rules.reduce((acc: { [actionsIds: string]: string[] }, rule) => { + if (rule instanceof Error) return acc; + rule.actions?.forEach(({ id }) => (acc[id] = [...(acc[id] || []), rule.rule_id])); + return acc; + }, {}); +export const checkIfActionsHaveMissingConnectors = ( + actionConnectors: SavedObject[], + newIdsToAdd: string[], + actionConnectorRules: ActionRules +) => { + // if new action-connectors don't have exported connectors will fail with missing connectors + if (actionConnectors.length < newIdsToAdd.length) { + const actionConnectorsIds = actionConnectors.map(({ id }) => id); + const missingActionConnector = newIdsToAdd.filter((id) => !actionConnectorsIds.includes(id)); + const missingActionRules = pick(actionConnectorRules, [...missingActionConnector]); + return handleActionsHaveNoConnectors(missingActionConnector, missingActionRules); + } + return null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts index e325496225d9f..4414b6063c473 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/create_rules_stream_from_ndjson.ts @@ -24,6 +24,7 @@ import type { ImportExceptionsListSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import type { SavedObject } from '@kbn/core-saved-objects-server'; import { RuleToImport, validateRuleToImport, @@ -41,7 +42,9 @@ export const validateRulesStream = (): Transform => { return createMapStream<{ exceptions: Array; rules: Array; + actionConnectors: SavedObject[]; }>((items) => ({ + actionConnectors: items.actionConnectors, exceptions: items.exceptions, rules: validateRules(items.rules), })); @@ -80,10 +83,14 @@ export const sortImports = (): Transform => { return createReduceStream<{ exceptions: Array; rules: Array; + actionConnectors: SavedObject[]; }>( (acc, importItem) => { if (has('list_id', importItem) || has('item_id', importItem) || has('entries', importItem)) { return { ...acc, exceptions: [...acc.exceptions, importItem] }; + } + if (has('attributes', importItem)) { + return { ...acc, actionConnectors: [...acc.actionConnectors, importItem] }; } else { return { ...acc, rules: [...acc.rules, importItem] }; } @@ -91,6 +98,7 @@ export const sortImports = (): Transform => { { exceptions: [], rules: [], + actionConnectors: [], } ); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts index 694bc916fefe3..5a8d6f9a34e87 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/import/import_rules_utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server'; import type { ImportExceptionsListSchema, ImportExceptionListItemSchema, @@ -31,6 +31,7 @@ export type PromiseFromStreams = RuleToImport | Error; export interface RuleExceptionsPromiseFromStreams { rules: PromiseFromStreams[]; exceptions: Array; + actionConnectors: SavedObject[]; } /** @@ -60,6 +61,7 @@ export const importRules = async ({ exceptionsClient, spaceId, existingLists, + allowMissingConnectorSecrets, }: { ruleChunks: PromiseFromStreams[][]; rulesResponseAcc: ImportRuleResponse[]; @@ -70,6 +72,7 @@ export const importRules = async ({ exceptionsClient: ExceptionListClient | undefined; spaceId: string; existingLists: Record; + allowMissingConnectorSecrets?: boolean; }) => { let importRuleResponse: ImportRuleResponse[] = [...rulesResponseAcc]; @@ -118,6 +121,7 @@ export const importRules = async ({ ...parsedRule, exceptions_list: [...exceptions], }, + allowMissingConnectorSecrets, }); resolve({ rule_id: parsedRule.rule_id, @@ -136,6 +140,7 @@ export const importRules = async ({ ...parsedRule, exceptions_list: [...exceptions], }, + allowMissingConnectorSecrets, }); resolve({ rule_id: parsedRule.rule_id, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 293d900a08ebd..e8e0e62fac045 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -63,6 +63,7 @@ import type { } from './types'; import { telemetryConfiguration } from './configuration'; import { ENDPOINT_METRICS_INDEX } from '../../../common/constants'; +import { PREBUILT_RULES_PACKAGE_NAME } from '../../../common/detection_engine/constants'; export interface ITelemetryReceiver { start( @@ -220,7 +221,7 @@ export class TelemetryReceiver implements ITelemetryReceiver { } public async fetchDetectionRulesPackageVersion(): Promise { - return this.packageService?.asInternalUser.getInstallation('security_detection_engine'); + return this.packageService?.asInternalUser.getInstallation(PREBUILT_RULES_PACKAGE_NAME); } public async fetchFleetAgents() { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 6db71726b3b50..6dee0730b5aa3 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -171,6 +171,8 @@ export class Plugin implements ISecuritySolutionPlugin { plugins, endpointAppContextService: this.endpointAppContextService, ruleExecutionLogService, + kibanaVersion: pluginContext.env.packageInfo.version, + kibanaBranch: pluginContext.env.packageInfo.branch, }); const router = core.http.createRouter(); @@ -357,6 +359,8 @@ export class Plugin implements ISecuritySolutionPlugin { appClientFactory.setup({ getSpaceId: depsStart.spaces?.spacesService?.getSpaceId, config, + kibanaVersion: pluginContext.env.packageInfo.version, + kibanaBranch: pluginContext.env.packageInfo.branch, }); const endpointFieldsStrategy = endpointFieldsProvider( diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index 0804fefe041e7..bead5d6088fd7 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -40,6 +40,8 @@ interface ConstructorOptions { plugins: SecuritySolutionPluginSetupDependencies; endpointAppContextService: EndpointAppContextService; ruleExecutionLogService: IRuleExecutionLogService; + kibanaVersion: string; + kibanaBranch: string; } export class RequestContextFactory implements IRequestContextFactory { @@ -64,6 +66,8 @@ export class RequestContextFactory implements IRequestContextFactory { appClientFactory.setup({ getSpaceId: startPlugins.spaces?.spacesService?.getSpaceId, config, + kibanaVersion: options.kibanaVersion, + kibanaBranch: options.kibanaBranch, }); // List of endpoint authz for the current request's user. Will be initialized the first diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 4522a35f793ec..531fd7c0a6f38 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -133,6 +133,8 @@ "@kbn/cypress-config", "@kbn/controls-plugin", "@kbn/shared-ux-utility", + "@kbn/core-saved-objects-common", + "@kbn/core-saved-objects-import-export-server-mocks", "@kbn/user-profile-components", "@kbn/guided-onboarding", "@kbn/securitysolution-ecs", diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/charts/duration_line_bar_list.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/charts/duration_line_bar_list.tsx index 6bafb9cef1d36..3fb96bf0336e1 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/common/charts/duration_line_bar_list.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/common/charts/duration_line_bar_list.tsx @@ -63,7 +63,7 @@ export const DurationAnomaliesBar = ({ anomalies, hiddenLegends }: Props) => { }; }; - const tooltipFormatter: AnnotationTooltipFormatter = (details?: string) => { + const TooltipFormatter: AnnotationTooltipFormatter = ({ details }) => { return ; }; @@ -76,7 +76,7 @@ export const DurationAnomaliesBar = ({ anomalies, hiddenLegends }: Props) => { key={keyIndex} id={keyIndex} style={getRectStyle(rectAnnotation.color)} - renderTooltip={tooltipFormatter} + renderTooltip={TooltipFormatter} /> ) : null; })} diff --git a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts index 0d227fe595415..f9ff6462327e3 100644 --- a/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/index_fields/index.ts @@ -40,6 +40,7 @@ export type BeatFields = Record; export interface IndexFieldsStrategyRequestByIndices extends IEsSearchRequest { indices: string[]; onlyCheckIfIndicesExist: boolean; + includeUnmapped?: boolean; } export interface IndexFieldsStrategyRequestById extends IEsSearchRequest { dataViewId: string; diff --git a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.test.ts b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.test.ts index abcf7c96b7d35..e4b821924b721 100644 --- a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.test.ts +++ b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.test.ts @@ -424,6 +424,33 @@ describe('Fields Provider', () => { expect(response.indexFields).toHaveLength(0); expect(response.indicesExist).toEqual([]); }); + + it('should search index fields with includeUnmapped option', async () => { + const indices = ['some-index-pattern-*']; + const request = { + indices, + includeUnmapped: true, + onlyCheckIfIndicesExist: false, + }; + + const response = await requestIndexFieldSearchHandler( + request, + deps, + beatFields, + getStartServices, + useInternalUser + ); + + expect(getFieldsForWildcardMock).toHaveBeenCalledWith({ + pattern: indices[0], + fieldCapsOptions: { + allow_no_indices: true, + includeUnmapped: true, + }, + }); + expect(response.indexFields).not.toHaveLength(0); + expect(response.indicesExist).toEqual(indices); + }); }); }); }); diff --git a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts index d1f04c65c09dc..b3b2f15799aea 100644 --- a/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/index_fields/index.ts @@ -8,6 +8,7 @@ import { from } from 'rxjs'; import isEmpty from 'lodash/isEmpty'; import get from 'lodash/get'; +import deepmerge from 'deepmerge'; import { ElasticsearchClient, StartServicesAccessor } from '@kbn/core/server'; import { DataViewsServerPluginStart, @@ -153,13 +154,18 @@ export const requestIndexFieldSearch = async ( const fieldDescriptor = ( await Promise.all( indicesExist.map(async (index, n) => { + const fieldCapsOptions = request.includeUnmapped + ? { includeUnmapped: true, allow_no_indices: true } + : undefined; if (index.startsWith('.alerts-observability') || useInternalUser) { return indexPatternsFetcherAsInternalUser.getFieldsForWildcard({ pattern: index, + fieldCapsOptions, }); } return indexPatternsFetcherAsCurrentUser.getFieldsForWildcard({ pattern: index, + fieldCapsOptions, }); }) ) @@ -264,7 +270,7 @@ export const createFieldItem = ( }; /** - * Iterates over each field, adds description, category, and indexes (index alias) + * Iterates over each field, adds description, category, conflictDescriptions, and indexes (index alias) * * This is a mutatious HOT CODE PATH function that will have array sizes up to 4.7 megs * in size at a time when being called. This function should be as optimized as possible @@ -299,6 +305,12 @@ export const formatIndexFields = async ( if (isEmpty(accumulator[alreadyExistingIndexField].description)) { accumulator[alreadyExistingIndexField].description = item.description; } + if (item.conflictDescriptions) { + accumulator[alreadyExistingIndexField].conflictDescriptions = deepmerge( + existingIndexField.conflictDescriptions ?? {}, + item.conflictDescriptions + ); + } accumulator[alreadyExistingIndexField].indexes = Array.from( new Set(existingIndexField.indexes.concat(item.indexes)) ); diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index dba104b3dcd2c..fe3675fb431d9 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -14,7 +14,8 @@ "share", "triggersActionsUi", "fieldFormats", - "unifiedSearch" + "unifiedSearch", + "charts" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 38e7456c5ba7f..3cbfbdb2182d0 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -13,6 +13,9 @@ import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; +import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; + import { SharePluginStart } from '@kbn/share-plugin/public'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; @@ -33,11 +36,13 @@ coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: const appDependencies: AppDependencies = { application: coreStart.application, + charts: chartPluginMock.createStartContract(), chrome: coreStart.chrome, data: dataStart, dataViews: dataViewsStart, docLinks: coreStart.docLinks, i18n: coreStart.i18n, + fieldFormats: fieldFormatsServiceMock.createStartContract(), notifications: coreSetup.notifications, uiSettings: coreStart.uiSettings, savedObjects: coreStart.savedObjects, diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index e6caef788c59a..2c4b5f0e9f925 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -30,14 +30,18 @@ import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; +import { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; export interface AppDependencies { application: ApplicationStart; + charts: ChartsPluginStart; chrome: ChromeStart; data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; docLinks: DocLinksStart; + fieldFormats: FieldFormatsStart; http: HttpSetup; i18n: I18nStart; notifications: NotificationsSetup; diff --git a/x-pack/plugins/transform/public/app/common/dropdown.ts b/x-pack/plugins/transform/public/app/common/dropdown.ts index d463e9a8368db..9fd65d0620e45 100644 --- a/x-pack/plugins/transform/public/app/common/dropdown.ts +++ b/x-pack/plugins/transform/public/app/common/dropdown.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { type EuiComboBoxOptionOption } from '@elastic/eui'; // The display label used for an aggregation e.g. sum(bytes). export type Label = string; @@ -14,7 +15,7 @@ export interface DropDownLabel { } // Label object structure for EUI's ComboBox with support for nesting. -export interface DropDownOption { +export interface DropDownOption extends EuiComboBoxOptionOption { label: Label; options: DropDownLabel[]; } diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index d4fbf1c77d054..f4d10b5562001 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -12,7 +12,7 @@ import type { EuiDataGridColumn } from '@elastic/eui'; import { buildBaseFilterCriteria } from '@kbn/ml-query-utils'; -import type { TimeRangeMs } from '../../../common/types/date_picker'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { isEsSearchResponse, isFieldHistogramsResponseSchema, diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 1ba7253f996fa..6d9ecfa66938d 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -29,7 +29,8 @@ export async function mountManagementSection( const startServices = await getStartServices(); const [core, plugins] = startServices; const { application, chrome, docLinks, i18n, overlays, theme, savedObjects, uiSettings } = core; - const { data, dataViews, share, spaces, triggersActionsUi, unifiedSearch } = plugins; + const { data, dataViews, share, spaces, triggersActionsUi, unifiedSearch, charts, fieldFormats } = + plugins; const { docTitle } = chrome; // Initialize services @@ -59,6 +60,8 @@ export async function mountManagementSection( ml: await getMlSharedImports(), triggersActionsUi, unifiedSearch, + charts, + fieldFormats, }; const unmountAppCallback = renderApp(element, appDependencies); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx index ea2e937ce38bb..ec2b83578ef72 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_dropdown/dropdown.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionsListProps, EuiComboBoxOptionOption } from '@elastic/eui'; interface Props { options: EuiComboBoxOptionOption[]; @@ -15,9 +15,13 @@ interface Props { changeHandler(d: EuiComboBoxOptionOption[]): void; testSubj?: string; isDisabled?: boolean; + renderOption?: EuiComboBoxOptionsListProps< + string | number | string[] | undefined + >['renderOption']; } export const DropDown: React.FC = ({ + renderOption, changeHandler, options, placeholder = 'Search ...', @@ -35,6 +39,7 @@ export const DropDown: React.FC = ({ isClearable={false} data-test-subj={testSubj} isDisabled={isDisabled} + renderOption={renderOption} /> ); }; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx index f75893a2e42d2..214158606d32e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/pivot_configuration/pivot_configuration.tsx @@ -5,12 +5,15 @@ * 2.0. */ -import React, { memo, FC, createContext } from 'react'; +import React, { memo, FC, createContext, useMemo } from 'react'; -import { EuiFormRow } from '@elastic/eui'; +import { EuiFormRow, type EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { type DropDownOptionWithField } from '../step_define/common/get_pivot_dropdown_options'; +import { DropDownOption } from '../../../../common'; +import { useAppDependencies } from '../../../../app_dependencies'; import { AggListForm } from '../aggregation_list'; import { DropDown } from '../aggregation_dropdown'; import { GroupByListForm } from '../group_by_list'; @@ -22,6 +25,11 @@ export const PivotConfigurationContext = createContext< export const PivotConfiguration: FC = memo( ({ actions, state }) => { + const { + ml: { useFieldStatsTrigger, FieldStatsInfoButton }, + } = useAppDependencies(); + const { handleFieldStatsButtonClick, closeFlyout, renderOption } = useFieldStatsTrigger(); + const { addAggregation, addGroupBy, @@ -34,6 +42,27 @@ export const PivotConfiguration: FC = memo( const { aggList, aggOptions, aggOptionsData, groupByList, groupByOptions, groupByOptionsData } = state; + const aggOptionsWithFieldStats: EuiComboBoxOptionOption[] = useMemo( + () => + aggOptions.map(({ label, field, options }: DropDownOptionWithField) => { + const aggOption: DropDownOption = { + isGroupLabelOption: true, + key: field.id, + // @ts-ignore Purposefully passing label as element instead of string + // for more robust rendering + label: ( + + ), + options: options ?? [], + }; + return aggOption; + }), + [aggOptions, FieldStatsInfoButton, handleFieldStatsButtonClick] + ); return ( = memo( defaultMessage: 'Add a group by field ...', })} testSubj="transformGroupBySelection" + renderOption={renderOption} /> @@ -70,12 +100,18 @@ export const PivotConfiguration: FC = memo( { + updateAggregation(aggName, pivotAggsConfig); + closeFlyout(); + }} deleteHandler={deleteAggregation} /> { + addAggregation(option); + closeFlyout(); + }} + options={aggOptionsWithFieldStats} placeholder={i18n.translate( 'xpack.transform.stepDefineForm.aggregationsPlaceholder', { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts index 87d80938eb6e2..6cbd7f6a7be6e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/get_pivot_dropdown_options.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { EuiComboBoxOptionOption } from '@elastic/eui'; import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types'; import { DataView } from '@kbn/data-views-plugin/public'; import { getNestedProperty } from '@kbn/ml-nested-property'; @@ -15,7 +14,6 @@ import { removeKeywordPostfix } from '../../../../../../../common/utils/field_ut import { isRuntimeMappings } from '../../../../../../../common/shared_imports'; import { - DropDownLabel, DropDownOption, PivotAggsConfigWithUiSupportDict, pivotAggsFieldSupport, @@ -54,16 +52,22 @@ export function getKibanaFieldTypeFromEsType(type: string): KBN_FIELD_TYPES { } } +export interface DropDownOptionWithField extends DropDownOption { + field: { + id: string; + type: ES_FIELD_TYPES; + }; +} export function getPivotDropdownOptions( dataView: DataView, runtimeMappings?: StepDefineExposedState['runtimeMappings'] ) { // The available group by options - const groupByOptions: EuiComboBoxOptionOption[] = []; + const groupByOptions: Array> = []; const groupByOptionsData: PivotGroupByConfigWithUiSupportDict = {}; // The available aggregations - const aggOptions: EuiComboBoxOptionOption[] = []; + const aggOptions: DropDownOptionWithField[] = []; const aggOptionsData: PivotAggsConfigWithUiSupportDict = {}; const ignoreFieldNames = ['_id', '_index', '_type']; @@ -104,7 +108,10 @@ export function getPivotDropdownOptions( const aggName = displayFieldName.replace(illegalEsAggNameChars, '').trim(); // Option name in the dropdown for the group-by is in the form of `sum(fieldname)`. const dropDownName = `${groupByAgg}(${displayFieldName})`; - const groupByOption: DropDownLabel = { label: dropDownName }; + const groupByOption: Omit = { + label: dropDownName, + field: { id: rawFieldName, type: field.type as KBN_FIELD_TYPES & ES_FIELD_TYPES }, + }; groupByOptions.push(groupByOption); groupByOptionsData[dropDownName] = getDefaultGroupByConfig( aggName, @@ -116,7 +123,11 @@ export function getPivotDropdownOptions( } // Aggregations - const aggOption: DropDownOption = { label: displayFieldName, options: [] }; + const aggOption: DropDownOptionWithField = { + label: displayFieldName, + options: [], + field: { id: rawFieldName, type: field.type as KBN_FIELD_TYPES & ES_FIELD_TYPES }, + }; const availableAggs: [] = getNestedProperty(pivotAggsFieldSupport, field.type); if (availableAggs !== undefined) { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts index 2e23cc0e9047f..570247d443e4f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/types.ts @@ -8,6 +8,7 @@ import { KBN_FIELD_TYPES } from '@kbn/field-types'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { EsFieldName } from '../../../../../../../common/types/fields'; import { @@ -25,7 +26,6 @@ import { } from '../../../../../../../common/types/transform'; import { LatestFunctionConfig } from '../../../../../../../common/api_schemas/transforms'; import { RUNTIME_FIELD_TYPES } from '../../../../../../../common/shared_imports'; -import type { TimeRangeMs } from '../../../../../../../common/types/date_picker'; export interface ErrorMessage { query: string; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_date_picker.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_date_picker.ts index 64a825e424220..3000d15cc3caf 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_date_picker.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_date_picker.ts @@ -11,7 +11,7 @@ import { merge } from 'rxjs'; import type { TimeRange } from '@kbn/es-query'; import { mlTimefilterRefresh$, useTimefilter } from '@kbn/ml-date-picker'; -import type { TimeRangeMs } from '../../../../../../../common/types/date_picker'; +import type { TimeRange as TimeRangeMs } from '@kbn/ml-date-picker'; import { StepDefineExposedState } from '../common'; import { StepDefineFormProps } from '../step_define_form'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts index 4e395cf89335e..eded0073e17e8 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/hooks/use_latest_function_config.ts @@ -51,7 +51,14 @@ function getOptions( const ignoreFieldNames = new Set(['_source', '_type', '_index', '_id', '_version', '_score']); const runtimeFieldsOptions = runtimeMappings - ? Object.keys(runtimeMappings).map((k) => ({ label: k, value: k })) + ? Object.entries(runtimeMappings).map(([fieldName, fieldMapping]) => ({ + label: fieldName, + value: fieldName, + field: { + id: fieldName, + type: fieldMapping.type, + }, + })) : []; const uniqueKeyOptions: Array> = filteredDataViewFields @@ -59,6 +66,10 @@ function getOptions( .map((v) => ({ label: v.displayName, value: v.name, + field: { + id: v.name, + type: Array.isArray(v.esTypes) && v.esTypes?.length > 0 ? v.esTypes[0] : 'keyword', + }, })); const runtimeFieldsSortOptions: Array> = runtimeMappings @@ -67,6 +78,10 @@ function getOptions( .map(([fieldName, fieldMapping]) => ({ label: fieldName, value: fieldName, + field: { + id: fieldName, + type: fieldMapping.type, + }, })) : []; @@ -76,6 +91,10 @@ function getOptions( .map((v) => ({ label: v.displayName, value: v.name, + field: { + id: v.name, + type: Array.isArray(v.esTypes) && v.esTypes?.length > 0 ? v.esTypes[0] : 'keyword', + }, })); const sortByLabel = (a: EuiComboBoxOptionOption, b: EuiComboBoxOptionOption) => diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx index 64bedfa61479c..a3b9aba28a8d6 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/latest_function_form.tsx @@ -9,6 +9,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonIcon, EuiCallOut, EuiComboBox, EuiCopy, EuiFormRow } from '@elastic/eui'; +import { useAppDependencies } from '../../../../app_dependencies'; import { LatestFunctionService } from './hooks/use_latest_function_config'; interface LatestFunctionFormProps { @@ -22,6 +23,10 @@ export const LatestFunctionForm: FC = ({ copyToClipboardDescription, latestFunctionService, }) => { + const { + ml: { useFieldStatsTrigger }, + } = useAppDependencies(); + const { renderOption, closeFlyout } = useFieldStatsTrigger(); return ( <> = ({ latestFunctionService.updateLatestFunctionConfig({ unique_key: selected, }); + closeFlyout(); }} isClearable={false} data-test-subj="transformWizardUniqueKeysSelector" + renderOption={renderOption} /> = ({ latestFunctionService.updateLatestFunctionConfig({ sort: { value: selected[0].value, label: selected[0].label as string }, }); + closeFlyout(); }} isClearable={false} data-test-subj="transformWizardSortFieldSelector" + renderOption={renderOption} /> )} {latestFunctionService.sortFieldOptions.length === 0 && ( diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index f0ad9227ac672..cabbd37dfdb0f 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -19,6 +19,7 @@ import { UrlStateProvider } from '@kbn/ml-url-state'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { FieldStatsServices } from '@kbn/unified-field-list-plugin/public'; import type { TransformConfigUnion } from '../../../../../../common/types/transform'; import { getCreateTransformRequestBody } from '../../../../common'; @@ -107,6 +108,13 @@ export const CreateTransformWizardContext = createContext<{ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) => { const appDependencies = useAppDependencies(); + const { + ml: { FieldStatsFlyoutProvider }, + uiSettings, + data, + fieldFormats, + charts, + } = appDependencies; const { dataView } = searchItems; // The current WIZARD_STEP @@ -226,17 +234,35 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) uiSettingsKeys: UI_SETTINGS, }; + const fieldStatsServices: FieldStatsServices = useMemo( + () => ({ + uiSettings, + dataViews: data.dataViews, + data, + fieldFormats, + charts, + }), + [uiSettings, data, fieldFormats, charts] + ); + return ( - - - - - - - - - + + + + + + + + + + ); }); diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 25f8845644f45..6a14491b9eef7 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -18,10 +18,13 @@ import type { SpacesApi } from '@kbn/spaces-plugin/public'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import { ChartsPluginStart } from '@kbn/charts-plugin/public'; +import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { registerFeature } from './register_feature'; import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { + charts: ChartsPluginStart; data: DataPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; dataViews: DataViewsPublicPluginStart; @@ -32,6 +35,7 @@ export interface PluginsDependencies { spaces?: SpacesApi; alerting?: AlertingSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + fieldFormats: FieldFormatsStart; } export class TransformUiPlugin { diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json index ca4191088a8b1..104b61f276cf7 100644 --- a/x-pack/plugins/transform/tsconfig.json +++ b/x-pack/plugins/transform/tsconfig.json @@ -50,6 +50,9 @@ "@kbn/ml-url-state", "@kbn/ml-local-storage", "@kbn/ml-query-utils", + "@kbn/charts-plugin", + "@kbn/field-formats-plugin", + "@kbn/unified-field-list-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ee29f1d2dc710..2a85faa2eb9e4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -316,15 +316,6 @@ "console.welcomePage.closeButtonLabel": "Rejeter", "console.welcomePage.pageTitle": "Bienvenue dans la console", "console.welcomePage.quickIntroDescription": "L'interface utilisateur de la console est divisée en deux volets : un volet éditeur (à gauche) et un volet de réponse (à droite). L'éditeur permet de saisir des requêtes et de les envoyer à Elasticsearch, tandis que le volet de réponse affiche les résultats.", - "console.welcomePage.quickIntroTitle": "Introduction rapide à l'interface utilisateur", - "console.welcomePage.quickTips.cUrlFormatForRequestsDescription": "Vous pouvez coller des requêtes au format cURL ; elles seront automatiquement traduites dans la syntaxe de la console.", - "console.welcomePage.quickTips.keyboardShortcutsDescription": "N’hésitez pas à jeter un œil aux raccourcis clavier sous le bouton Aide. Vous pourriez y trouver des choses utiles.", - "console.welcomePage.quickTips.resizeEditorDescription": "Vous pouvez redimensionner les volets de l'éditeur et de réponse en faisant glisser le séparateur situé entre les deux.", - "console.welcomePage.quickTips.submitRequestDescription": "Utilisez l’icône de triangle vert pour envoyer vos requêtes à ES.", - "console.welcomePage.quickTips.useWrenchMenuDescription": "Cliquez sur l’icône en forme de clé pour découvrir d'autres éléments utiles.", - "console.welcomePage.quickTipsTitle": "Quelques brèves astuces, pendant que j'ai toute votre attention :", - "console.welcomePage.supportedRequestFormatDescription": "Lors de la saisie d'une requête, la console fera des suggestions que vous pourrez accepter en appuyant sur Entrée/Tab. Ces suggestions sont faites en fonction de la structure de la requête, des index et des types.", - "console.welcomePage.supportedRequestFormatTitle": "La console prend en charge les requêtes dans un format compact, tel que le format cURL :", "contentManagement.inspector.metadataForm.unableToSaveDangerMessage": "Impossible d'enregistrer {entityName}", "contentManagement.tableList.listing.createNewItemButtonLabel": "Créer {entityName}", "contentManagement.tableList.listing.deleteButtonMessage": "Supprimer {itemCount} {entityName}", @@ -7524,7 +7515,6 @@ "xpack.apm.serviceOverview.embeddedMap.error": "Impossible de charger la carte", "xpack.apm.serviceOverview.embeddedMap.error.toastTitle": "Une erreur s'est produite lors de l'ajout de l'incorporation de la carte", "xpack.apm.serviceOverview.embeddedMap.input.title": "Latence par pays", - "xpack.apm.serviceOverview.embeddedMap.metric.label": "Durée du chargement de la page", "xpack.apm.serviceOverview.embeddedMap.title": "Latence moyenne par pays", "xpack.apm.serviceOverview.errorsTable.errorMessage": "Impossible de récupérer", "xpack.apm.serviceOverview.errorsTable.loading": "Chargement...", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0177c90bc496e..4e61b0224cc7b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -316,15 +316,6 @@ "console.welcomePage.closeButtonLabel": "閉じる", "console.welcomePage.pageTitle": "コンソールへようこそ", "console.welcomePage.quickIntroDescription": "コンソール UI は、エディターペイン(左)と応答ペイン(右)の 2 つのペインに分かれています。エディターでリクエストを入力し、Elasticsearch に送信します。結果が右側の応答ペインに表示されます。", - "console.welcomePage.quickIntroTitle": "UI の簡単な説明", - "console.welcomePage.quickTips.cUrlFormatForRequestsDescription": "cURL フォーマットのリクエストを貼り付けると、Console 構文に変換されます。", - "console.welcomePage.quickTips.keyboardShortcutsDescription": "ヘルプボタンでキーボードショートカットが学べます。便利な情報が揃っています!", - "console.welcomePage.quickTips.resizeEditorDescription": "間の区切りをドラッグすることで、エディターとアウトプットペインのサイズを変更できます。", - "console.welcomePage.quickTips.submitRequestDescription": "緑の三角形のボタンをクリックして ES にリクエストを送信します。", - "console.welcomePage.quickTips.useWrenchMenuDescription": "レンチメニューで他の便利な機能が使えます。", - "console.welcomePage.quickTipsTitle": "今のうちにいくつか簡単なコツをお教えします", - "console.welcomePage.supportedRequestFormatDescription": "リクエストの入力中、コンソールが候補を提案するので、Enter/Tabを押して確定できます。これらの候補はリクエストの構造、およびインデックス、タイプに基づくものです。", - "console.welcomePage.supportedRequestFormatTitle": "コンソールは cURL と同様に、コンパクトなフォーマットのリクエストを理解できます。", "contentManagement.inspector.metadataForm.unableToSaveDangerMessage": "{entityName}を保存できません", "contentManagement.tableList.listing.createNewItemButtonLabel": "Create {entityName}", "contentManagement.tableList.listing.deleteButtonMessage": "{itemCount} 件の {entityName} を削除", @@ -7513,7 +7504,6 @@ "xpack.apm.serviceOverview.embeddedMap.error": "マップを読み込めませんでした", "xpack.apm.serviceOverview.embeddedMap.error.toastTitle": "埋め込み可能なマップの追加中にエラーが発生しました", "xpack.apm.serviceOverview.embeddedMap.input.title": "国別レイテンシ", - "xpack.apm.serviceOverview.embeddedMap.metric.label": "ページ読み込み時間", "xpack.apm.serviceOverview.embeddedMap.title": "国別平均レイテンシ", "xpack.apm.serviceOverview.errorsTable.errorMessage": "取得できませんでした", "xpack.apm.serviceOverview.errorsTable.loading": "読み込み中...", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bcb720385e0dd..d581efbba60fe 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -316,15 +316,6 @@ "console.welcomePage.closeButtonLabel": "关闭", "console.welcomePage.pageTitle": "欢迎使用 Console", "console.welcomePage.quickIntroDescription": "Console UI 分为两个窗格:编辑器窗格(左)和响应窗格(右)。使用编辑器键入请求并将它们提交到 Elasticsearch。结果将显示在右侧的响应窗格中。", - "console.welcomePage.quickIntroTitle": "UI 简介", - "console.welcomePage.quickTips.cUrlFormatForRequestsDescription": "您可以粘贴 cURL 格式的请求,这些请求将转换成 Console 语法格式。", - "console.welcomePage.quickTips.keyboardShortcutsDescription": "学习“帮助”按钮下的键盘快捷方式。那里有非常实用的信息!", - "console.welcomePage.quickTips.resizeEditorDescription": "您可以通过拖动编辑器和输出窗格之间的分隔条来调整它们的大小。", - "console.welcomePage.quickTips.submitRequestDescription": "使用绿色三角按钮将请求提交到 ES。", - "console.welcomePage.quickTips.useWrenchMenuDescription": "使用扳手菜单执行其他有用的操作。", - "console.welcomePage.quickTipsTitle": "有几个需要您注意的有用提示", - "console.welcomePage.supportedRequestFormatDescription": "键入请求时,控制台将提供建议,您可以通过按 Enter/Tab 键来接受建议。这些建议基于请求结构以及索引和类型进行提供。", - "console.welcomePage.supportedRequestFormatTitle": "Console 理解紧凑格式的请求,类似于 cURL:", "contentManagement.inspector.metadataForm.unableToSaveDangerMessage": "无法保存 {entityName}", "contentManagement.tableList.listing.createNewItemButtonLabel": "创建 {entityName}", "contentManagement.tableList.listing.deleteButtonMessage": "删除 {itemCount} 个 {entityName}", @@ -7527,7 +7518,6 @@ "xpack.apm.serviceOverview.embeddedMap.error": "无法加载地图", "xpack.apm.serviceOverview.embeddedMap.error.toastTitle": "加载地图可嵌入对象时出错", "xpack.apm.serviceOverview.embeddedMap.input.title": "延迟(按国家/地区)", - "xpack.apm.serviceOverview.embeddedMap.metric.label": "页面加载持续时间", "xpack.apm.serviceOverview.embeddedMap.title": "每个国家/地区的平均延迟", "xpack.apm.serviceOverview.errorsTable.errorMessage": "无法提取", "xpack.apm.serviceOverview.errorsTable.loading": "正在加载……", diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts index 8b80e0d0eab0c..60dc074044ba1 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('cloud_security_posture', function () { this.tags(['cloud_security_posture']); + loadTestFile(require.resolve('./status')); // Place your tests files under this directory and add the following here: // loadTestFile(require.resolve('./your test name')); diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/status.ts b/x-pack/test/api_integration/apis/cloud_security_posture/status.ts new file mode 100644 index 0000000000000..6d10aa2f60f4a --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud_security_posture/status.ts @@ -0,0 +1,125 @@ +/* + * Copyright 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 expect from '@kbn/expect'; +import type { CspSetupStatus } from '@kbn/cloud-security-posture-plugin/common/types'; +import type { SuperTest, Test } from 'supertest'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + describe('GET /internal/cloud_security_posture/status', () => { + let agentPolicyId: string; + + beforeEach(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + + const { body: agentPolicyResponse } = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + }); + agentPolicyId = agentPolicyResponse.item.id; + }); + + afterEach(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + }); + + it(`Should return not-deployed when installed kspm`, async () => { + await createPackagePolicy( + supertest, + agentPolicyId, + 'kspm', + 'cloudbeat/cis_k8s', + 'vanilla', + 'kspm' + ); + + const { body: res }: { body: CspSetupStatus } = await supertest + .get(`/internal/cloud_security_posture/status`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(res.status).to.be('not-deployed'); + expect(res.installedPolicyTemplates).length(1).contain('kspm'); + expect(res.healthyAgents).to.be(0); + }); + + it(`Should return not-deployed when installed cspm`, async () => { + await createPackagePolicy( + supertest, + agentPolicyId, + 'cspm', + 'cloudbeat/cis_aws', + 'aws', + 'cspm' + ); + + const { body: res }: { body: CspSetupStatus } = await supertest + .get(`/internal/cloud_security_posture/status`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(res.status).to.be('not-deployed'); + expect(res.installedPolicyTemplates).length(1).contain('cspm'); + expect(res.healthyAgents).to.be(0); + }); + }); +} + +async function createPackagePolicy( + supertest: SuperTest, + agentPolicyId: string, + policyTemplate: string, + input: string, + deployment: string, + posture: string +) { + const { body: postPackageResponse } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + force: true, + name: 'cloud_security_posture-1', + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + inputs: [ + { + enabled: true, + type: input, + policy_template: policyTemplate, + }, + ], + package: { + name: 'cloud_security_posture', + title: 'Kubernetes Security Posture Management', + version: '1.2.8', + }, + vars: { + deployment: { + value: deployment, + type: 'text', + }, + posture: { + value: posture, + type: 'text', + }, + }, + }) + .expect(200); + + return postPackageResponse.item; +} diff --git a/x-pack/test/cases_api_integration/common/lib/user_actions.ts b/x-pack/test/cases_api_integration/common/lib/user_actions.ts index d471fafca6b91..ebe1525ead4a0 100644 --- a/x-pack/test/cases_api_integration/common/lib/user_actions.ts +++ b/x-pack/test/cases_api_integration/common/lib/user_actions.ts @@ -14,6 +14,8 @@ import { CaseUserActionsDeprecatedResponse, getCaseUserActionStatsUrl, CaseUserActionStatsResponse, + GetCaseUsersResponse, + getCaseUsersUrl, } from '@kbn/cases-plugin/common/api'; import type SuperTest from 'supertest'; import { User } from './authentication/types'; @@ -89,3 +91,21 @@ export const getCaseUserActionStats = async ({ return userActionStats; }; + +export const getCaseUsers = async ({ + supertest, + caseId, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + caseId: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: users } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${getCaseUsersUrl(caseId)}`) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + return users; +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index c30cac8416bf4..743929b1b1182 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -240,7 +240,7 @@ export default ({ getService }: FtrProviderContext): void => { it('returns the correct fields', async () => { const postedCase = await createCase(supertest, postCaseReq); - // all fields that contain the UserRT definition must be included here (aka created_by, closed_by, and updated_by) + // all fields that contain the UserRt definition must be included here (aka created_by, closed_by, and updated_by) // see https://github.com/elastic/kibana/issues/139503 const queryFields: Array> = [ ['title', 'created_by', 'closed_by', 'updated_by'], diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts index b4392c95d5351..6914c23af656f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts @@ -49,6 +49,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./internal/bulk_get_cases')); loadTestFile(require.resolve('./internal/bulk_get_attachments')); loadTestFile(require.resolve('./internal/get_connectors')); + loadTestFile(require.resolve('./internal/user_actions_get_users')); /** * Attachments framework diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts new file mode 100644 index 0000000000000..703d8d4e49637 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/user_actions_get_users.ts @@ -0,0 +1,426 @@ +/* + * Copyright 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 expect from '@kbn/expect'; +import { Cookie } from 'tough-cookie'; +import { UserProfile } from '@kbn/security-plugin/common'; +import { securitySolutionOnlyAllSpacesRole } from '../../../../common/lib/authentication/roles'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { createCase, deleteAllCaseItems, getCase, updateCase } from '../../../../common/lib/utils'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getCaseUsers } from '../../../../common/lib/user_actions'; +import { loginUsers, bulkGetUserProfiles } from '../../../../common/lib/user_profiles'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication'; +import { + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + secOnlySpacesAll, + superUser, + noKibanaPrivileges, + globalRead, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + + describe('user_actions_get_users', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('no profiles', () => { + it('returns the users correctly without profile ids', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + await changeCaseTitle({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + title: 'new title', + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([{ username: 'elastic', full_name: null, email: null }]); + expect(assignees).to.eql([]); + expect(unassignedUsers).to.eql([]); + }); + }); + + describe('no users', () => { + before(async () => { + /** The following dataset contains: + * a) A case without all properties of the created_by set to null + * and an assignee with no valid uid + * b) A user action that removes an assignee that does not exists + */ + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_no_users.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_no_users.json' + ); + }); + + it('returns users without information', async () => { + const theCase = await getCase({ + supertest, + caseId: '163d5820-1284-21ed-81af-63a2bdfb2bf9', + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: theCase.id, + supertest, + }); + + expect(participants).to.eql([{ username: null, full_name: null, email: null }]); + expect(assignees).to.eql([{ profile_uid: 'abc' }]); + expect(unassignedUsers).to.eql([{ profile_uid: 'dfg' }]); + }); + }); + + describe('profiles', () => { + let cookies: Cookie[]; + let secUserProfile: UserProfile; + let superUserProfile: UserProfile; + let superUserHeaders: { Cookie: string }; + let secOnlyHeaders: { Cookie: string }; + + before(async () => { + await createUsersAndRoles( + getService, + [secOnlySpacesAll], + [securitySolutionOnlyAllSpacesRole] + ); + }); + + beforeEach(async () => { + cookies = await loginUsers({ + supertest: supertestWithoutAuth, + users: [superUser, secOnlySpacesAll], + }); + + superUserHeaders = { + Cookie: cookies[0].cookieString(), + }; + + secOnlyHeaders = { + Cookie: cookies[1].cookieString(), + }; + + /** + * We cannot call suggestUserProfiles on basic license. + * To get the profiles of the users we create a case and + * then we extract the profile ids from created_by.profile_uid + */ + const [superUserCase, secUserCase] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, null, superUserHeaders), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, null, secOnlyHeaders), + ]); + + const userProfiles = await bulkGetUserProfiles({ + supertest, + // @ts-expect-error: profile uids are defined for both users + req: { uids: [superUserCase.created_by.profile_uid, secUserCase.created_by.profile_uid] }, + }); + + superUserProfile = userProfiles[0]; + secUserProfile = userProfiles[1]; + }); + + after(async () => { + await deleteUsersAndRoles( + getService, + [secOnlySpacesAll], + [securitySolutionOnlyAllSpacesRole] + ); + }); + + it('returns only the creator of the case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([]); + expect(unassignedUsers).to.eql([]); + }); + + it('returns one participant if it is the only one that participates to the case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + await changeCaseTitle({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + title: 'new title', + headers: superUserHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([]); + expect(unassignedUsers).to.eql([]); + }); + + it('returns all participants of the case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + await changeCaseTitle({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + title: 'new title', + headers: secOnlyHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: secUserProfile.user.username, + full_name: secUserProfile.user.full_name, + email: secUserProfile.user.email, + profile_uid: secUserProfile.uid, + }, + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([]); + expect(unassignedUsers).to.eql([]); + }); + + it('does not return duplicate participants', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + const updatedCases = await changeCaseTitle({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + title: 'new title', + headers: secOnlyHeaders, + }); + + await changeCaseDescription({ + supertest, + caseId: updatedCases[0].id, + version: updatedCases[0].version, + description: 'new desc', + headers: secOnlyHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: secUserProfile.user.username, + full_name: secUserProfile.user.full_name, + email: secUserProfile.user.email, + profile_uid: secUserProfile.uid, + }, + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([]); + expect(unassignedUsers).to.eql([]); + }); + }); + + describe('rbac', () => { + it('should retrieve the users for a case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) { + await getCaseUsers({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user, space: 'space1' }, + }); + } + }); + + it('should not get the users for a case when the user does not have access to the owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + for (const user of [noKibanaPrivileges, obsOnly, obsOnlyRead]) { + await getCaseUsers({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + expectedHttpCode: 403, + auth: { user, space: 'space1' }, + }); + } + }); + + it('should not get a case in a space the user does not have permissions to', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + await getCaseUsers({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + expectedHttpCode: 403, + auth: { user: secOnly, space: 'space2' }, + }); + }); + }); + }); +}; + +type UserActionParams = Omit[0], 'params'> & { + caseId: string; + version: string; +} & T; + +const changeCaseTitle = async ({ + supertest, + caseId, + version, + title, + expectedHttpCode, + auth, + headers, +}: UserActionParams<{ title: string }>) => + updateCase({ + supertest, + params: { + cases: [ + { + id: caseId, + version, + title, + }, + ], + }, + expectedHttpCode, + auth, + headers, + }); + +const changeCaseDescription = async ({ + supertest, + caseId, + version, + description, + expectedHttpCode, + auth, + headers, +}: UserActionParams<{ description: string }>) => + updateCase({ + supertest, + params: { + cases: [ + { + id: caseId, + version, + description, + }, + ], + }, + expectedHttpCode, + auth, + headers, + }); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index a167b8ded642a..65391b73f9f62 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -41,6 +41,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./internal/get_user_action_stats')); loadTestFile(require.resolve('./internal/suggest_user_profiles')); loadTestFile(require.resolve('./internal/get_connectors')); + loadTestFile(require.resolve('./internal/user_actions_get_users')); // Common loadTestFile(require.resolve('../common')); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/user_actions_get_users.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/user_actions_get_users.ts new file mode 100644 index 0000000000000..766c4e9f35c77 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/user_actions_get_users.ts @@ -0,0 +1,297 @@ +/* + * Copyright 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 expect from '@kbn/expect'; +import { Cookie } from 'tough-cookie'; +import { UserProfile } from '@kbn/security-plugin/common'; +import { securitySolutionOnlyAllSpacesRole } from '../../../../common/lib/authentication/roles'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { createCase, deleteAllCaseItems, updateCase } from '../../../../common/lib/utils'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getCaseUsers } from '../../../../common/lib/user_actions'; +import { loginUsers, bulkGetUserProfiles } from '../../../../common/lib/user_profiles'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication'; +import { secOnlySpacesAll, superUser } from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('user_actions_get_users', () => { + describe('profiles', () => { + let cookies: Cookie[]; + let secUserProfile: UserProfile; + let superUserProfile: UserProfile; + let superUserHeaders: { Cookie: string }; + let secOnlyHeaders: { Cookie: string }; + + before(async () => { + await createUsersAndRoles( + getService, + [secOnlySpacesAll], + [securitySolutionOnlyAllSpacesRole] + ); + }); + + beforeEach(async () => { + cookies = await loginUsers({ + supertest: supertestWithoutAuth, + users: [superUser, secOnlySpacesAll], + }); + + superUserHeaders = { + Cookie: cookies[0].cookieString(), + }; + + secOnlyHeaders = { + Cookie: cookies[1].cookieString(), + }; + + /** + * We cannot call suggestUserProfiles on basic license. + * To get the profiles of the users we create a case and + * then we extract the profile ids from created_by.profile_uid + */ + const [superUserCase, secUserCase] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, null, superUserHeaders), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, null, secOnlyHeaders), + ]); + + const userProfiles = await bulkGetUserProfiles({ + supertest, + // @ts-expect-error: profile uids are defined for both users + req: { uids: [superUserCase.created_by.profile_uid, secUserCase.created_by.profile_uid] }, + }); + + superUserProfile = userProfiles[0]; + secUserProfile = userProfiles[1]; + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + after(async () => { + await deleteUsersAndRoles( + getService, + [secOnlySpacesAll], + [securitySolutionOnlyAllSpacesRole] + ); + }); + + it('returns users correctly when assigning users to a case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + // assignee superUser and secUserProfile + await setAssignees({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + assignees: [{ uid: superUserProfile.uid }, { uid: secUserProfile.uid }], + headers: superUserHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + { + username: secUserProfile.user.username, + full_name: secUserProfile.user.full_name, + email: secUserProfile.user.email, + profile_uid: secUserProfile.uid, + }, + ]); + + expect(unassignedUsers).to.eql([]); + }); + + it('returns users correctly when de-assigning users to a case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + // assignee superUser and secUserProfile + const updatedCase = await setAssignees({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + assignees: [{ uid: superUserProfile.uid }, { uid: secUserProfile.uid }], + headers: superUserHeaders, + }); + + // de-assignee secUser + await setAssignees({ + supertest, + caseId: updatedCase[0].id, + version: updatedCase[0].version, + assignees: [{ uid: superUserProfile.uid }], + headers: superUserHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(unassignedUsers).to.eql([ + { + username: secUserProfile.user.username, + full_name: secUserProfile.user.full_name, + email: secUserProfile.user.email, + profile_uid: secUserProfile.uid, + }, + ]); + }); + + it('does not return duplicate users', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + null, + superUserHeaders + ); + + // assignee superUser and secUserProfile + const updatedCase = await setAssignees({ + supertest, + caseId: postedCase.id, + version: postedCase.version, + assignees: [{ uid: superUserProfile.uid }, { uid: secUserProfile.uid }], + headers: superUserHeaders, + }); + + // de-assignee secUser + const updatedCase2 = await setAssignees({ + supertest, + caseId: updatedCase[0].id, + version: updatedCase[0].version, + assignees: [{ uid: superUserProfile.uid }], + headers: superUserHeaders, + }); + + // re-assign secUser + await setAssignees({ + supertest, + caseId: updatedCase2[0].id, + version: updatedCase2[0].version, + assignees: [{ uid: superUserProfile.uid }, { uid: secUserProfile.uid }], + headers: superUserHeaders, + }); + + const { participants, assignees, unassignedUsers } = await getCaseUsers({ + caseId: postedCase.id, + supertest, + }); + + expect(participants).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + ]); + + expect(assignees).to.eql([ + { + username: superUserProfile.user.username, + full_name: superUserProfile.user.full_name, + email: superUserProfile.user.email, + profile_uid: superUserProfile.uid, + }, + { + username: secUserProfile.user.username, + full_name: secUserProfile.user.full_name, + email: secUserProfile.user.email, + profile_uid: secUserProfile.uid, + }, + ]); + + expect(unassignedUsers).to.eql([]); + }); + }); + }); +}; + +type UserActionParams = Omit[0], 'params'> & { + caseId: string; + version: string; +} & T; + +const setAssignees = async ({ + supertest, + caseId, + version, + assignees, + expectedHttpCode, + auth, + headers, +}: UserActionParams<{ assignees: Array<{ uid: string }> }>) => + updateCase({ + supertest, + params: { + cases: [ + { + id: caseId, + version, + assignees, + }, + ], + }, + expectedHttpCode, + auth, + headers, + }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts index 833b9ccb9ee1f..2d1ddd342a549 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts @@ -87,6 +87,11 @@ export default ({ getService }: FtrProviderContext): void => { missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts index d32e93b481547..2630174529b75 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/import_rules.ts @@ -73,6 +73,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -147,6 +151,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -170,6 +178,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -227,6 +239,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -245,6 +261,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -277,6 +297,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -301,6 +325,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -365,6 +393,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -404,6 +436,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts index b30e16681d488..60af78ec6cff9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/export_rules.ts @@ -88,6 +88,11 @@ export default ({ getService }: FtrProviderContext): void => { missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts index 48c4408e8555e..169fe074604fa 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/import_rules.ts @@ -135,9 +135,14 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); - it('should successfully import rules with actions when user has "read" actions privileges', async () => { + + it('should not import rules with actions when user has "read" actions privileges', async () => { // create a new action const { body: hookAction } = await supertest .post('/api/actions/action') @@ -149,27 +154,71 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', action_type_id: hookAction.actionTypeId, params: {}, }, ], }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter, 'changeme') .set('kbn-xsrf', 'true') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) .expect(200); expect(body).to.eql({ - errors: [], - success: true, - success_count: 1, + errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + success: false, + success_count: 0, rules_count: 1, exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], }); }); it('should not import rules with actions when a user has no actions privileges', async () => { @@ -184,17 +233,39 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', action_type_id: hookAction.actionTypeId, params: {}, }, ], }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57axy1', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertestWithoutAuth .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .auth(ROLES.hunter_no_actions, 'changeme') .set('kbn-xsrf', 'true') - .attach('file', ruleToNdjson(simpleRule), 'rules.ndjson') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) .expect(200); expect(body).to.eql({ success: false, @@ -208,18 +279,24 @@ export default ({ getService }: FtrProviderContext): void => { }, rule_id: '(unknown id)', }, - { - error: { - message: `1 connector is missing. Connector id missing is: ${hookAction.id}`, - status_code: 404, - }, - rule_id: 'rule-1', - }, ], rules_count: 1, exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + message: + 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + status_code: 403, + }, + rule_id: '(unknown id)', + }, + ], + action_connectors_warnings: [], }); }); }); @@ -367,6 +444,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -404,6 +485,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -430,6 +515,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -448,6 +537,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -480,6 +573,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -504,6 +601,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -568,6 +669,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -607,6 +712,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -688,6 +797,78 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_warnings: [], + action_connectors_errors: [ + { + rule_id: 'rule-1', + error: { + status_code: 404, + message: '1 connector is missing. Connector id missing is: 123', + }, + }, + ], + }); + }); + it('should give single connector warning back if we have a single connector missing secret', async () => { + const simpleRule: ReturnType = { + ...getSimpleRule('rule-1'), + actions: [ + { + group: 'default', + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + action_type_id: '.webhook', + params: {}, + }, + ], + }; + const ruleWithConnector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aaf9', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: true, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach( + 'file', + Buffer.from(toNdJsonString([simpleRule, ruleWithConnector])), + 'rules.ndjson' + ) + .expect(200); + + expect(body).to.eql({ + success: true, + success_count: 1, + rules_count: 1, + errors: [], + exceptions_errors: [], + exceptions_success: true, + exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 1, + action_connectors_warnings: [ + { + actionPath: '/app/management/insightsAndAlerting/triggersActionsConnectors', + buttonLabel: 'Go to connectors', + message: '1 connector has sensitive information that require updates.', + type: 'action_required', + }, + ], + action_connectors_errors: [], }); }); @@ -709,6 +890,7 @@ export default ({ getService }: FtrProviderContext): void => { }, ], }; + const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) .set('kbn-xsrf', 'true') @@ -722,10 +904,14 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); - it('should be able to import 2 rules with action connectors that exist', async () => { + it('should be able to import 2 rules with action connectors', async () => { // create a new action const { body: hookAction } = await supertest .post('/api/actions/action') @@ -738,7 +924,7 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', action_type_id: hookAction.actionTypeId, params: {}, }, @@ -750,18 +936,57 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, - action_type_id: hookAction.actionTypeId, + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + action_type_id: '.index', params: {}, }, ], }; + + const connector1 = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57abc6', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const connector2 = { + id: 'f4e74ab0-9e59-11ed-a3db-f9134a9ce951', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.index', + name: 'index', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; const rule1String = JSON.stringify(rule1); const rule2String = JSON.stringify(rule2); - const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`); + const connector12String = JSON.stringify(connector1); + const connector22String = JSON.stringify(connector2); + const buffer = Buffer.from( + `${rule1String}\n${rule2String}\n${connector12String}\n${connector22String}\n` + ); const { body } = await supertest - .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .post(`${DETECTION_ENGINE_RULES_URL}/_import?overwrite=true`) .set('kbn-xsrf', 'true') .attach('file', buffer, 'rules.ndjson') .expect(200); @@ -774,6 +999,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 2, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -790,7 +1019,7 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: hookAction.id, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', action_type_id: hookAction.actionTypeId, params: {}, }, @@ -802,15 +1031,36 @@ export default ({ getService }: FtrProviderContext): void => { actions: [ { group: 'default', - id: '123', // <-- This does not exist - action_type_id: hookAction.actionTypeId, + id: 'cabc78e0-9031-11ed-b076-53cc4d57aa22', // <-- This does not exist + action_type_id: '.index', params: {}, }, ], }; + + const connector = { + id: 'cabc78e0-9031-11ed-b076-53cc4d57aayo', + type: 'action', + updated_at: '2023-01-25T14:35:52.852Z', + created_at: '2023-01-25T14:35:52.852Z', + version: 'WzUxNTksMV0=', + attributes: { + actionTypeId: '.webhook', + name: 'webhook', + isMissingSecrets: false, + config: {}, + secrets: {}, + }, + references: [], + migrationVersion: { action: '8.3.0' }, + coreMigrationVersion: '8.7.0', + }; + const rule1String = JSON.stringify(rule1); const rule2String = JSON.stringify(rule2); - const buffer = Buffer.from(`${rule1String}\n${rule2String}\n`); + const connector2String = JSON.stringify(connector); + + const buffer = Buffer.from(`${rule1String}\n${rule2String}\n${connector2String}\n`); const { body } = await supertest .post(`${DETECTION_ENGINE_RULES_URL}/_import`) @@ -820,20 +1070,34 @@ export default ({ getService }: FtrProviderContext): void => { expect(body).to.eql({ success: false, - success_count: 1, + success_count: 0, rules_count: 2, errors: [ { rule_id: 'rule-2', error: { status_code: 404, - message: '1 connector is missing. Connector id missing is: 123', + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aa22', }, }, ], exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: false, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + error: { + status_code: 404, + message: + '1 connector is missing. Connector id missing is: cabc78e0-9031-11ed-b076-53cc4d57aa22', + }, + rule_id: 'rule-2', + }, + ], + action_connectors_warnings: [], }); }); @@ -974,6 +1238,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1045,6 +1313,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1144,6 +1416,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1274,6 +1550,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts index 06e55fdfdda1b..13f1f99fc3b23 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/perform_bulk_action.ts @@ -106,6 +106,100 @@ export default ({ getService }: FtrProviderContext): void => { missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 0, + missing_action_connection_count: 0, + missing_action_connections: [], + }); + }); + it('should export rules with actions connectors', async () => { + // create new actions + const webHookAction = await createWebHookConnector(); + + const defaultRuleAction = { + id: webHookAction.id, + action_type_id: '.webhook', + group: 'default', + params: { + body: '{"test":"a default action"}', + }, + }; + + const ruleId = 'rule-1'; + await createRule(supertest, log, { + ...getSimpleRule(ruleId), + actions: [defaultRuleAction], + }); + const exportedConnectors = { + attributes: { + actionTypeId: '.webhook', + config: { + hasAuth: true, + method: 'post', + url: 'http://localhost', + }, + isMissingSecrets: true, + name: 'Some connector', + secrets: {}, + }, + coreMigrationVersion: '8.7.0', + id: webHookAction.id, + migrationVersion: { + action: '8.3.0', + }, + references: [], + type: 'action', + }; + + const { body } = await postBulkAction() + .send({ query: '', action: BulkActionType.export }) + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') + .parse(binaryToString); + + const [ruleJson, connectorsJson, exportDetailsJson] = body.toString().split(/\n/); + + const rule = removeServerGeneratedProperties(JSON.parse(ruleJson)); + expect(rule).to.eql({ + ...getSimpleRuleOutput(), + throttle: 'rule', + actions: [ + { + action_type_id: '.webhook', + group: 'default', + id: webHookAction.id, + params: { + body: '{"test":"a default action"}', + }, + }, + ], + }); + const { attributes, id, type } = JSON.parse(connectorsJson); + expect(attributes.actionTypeId).to.eql(exportedConnectors.attributes.actionTypeId); + expect(id).to.eql(exportedConnectors.id); + expect(type).to.eql(exportedConnectors.type); + expect(attributes.name).to.eql(exportedConnectors.attributes.name); + expect(attributes.secrets).to.eql(exportedConnectors.attributes.secrets); + expect(attributes.isMissingSecrets).to.eql(exportedConnectors.attributes.isMissingSecrets); + const exportDetails = JSON.parse(exportDetailsJson); + expect(exportDetails).to.eql({ + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_count: 2, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + missing_rules: [], + missing_rules_count: 0, + excluded_action_connection_count: 0, + excluded_action_connections: [], + exported_action_connector_count: 1, + missing_action_connection_count: 0, + missing_action_connections: [], }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts index 3ff748fa5259d..135bf49c15ff9 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/import_rules.ts @@ -133,6 +133,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); it('should successfully import rules with actions when user has "read" actions privileges', async () => { @@ -168,6 +172,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); it('should not import rules with actions when a user has no actions privileges', async () => { @@ -202,7 +210,7 @@ export default ({ getService }: FtrProviderContext): void => { { error: { message: - 'You may not have actions privileges required to import rules with actions: Unauthorized to get actions', + 'You may not have actions privileges required to import rules with actions: Unable to bulk_create action', status_code: 403, }, rule_id: '(unknown id)', @@ -218,6 +226,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); @@ -268,6 +280,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -305,6 +321,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -331,6 +351,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -349,6 +373,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -381,6 +409,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -405,6 +437,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -469,6 +505,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -508,6 +548,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -589,6 +633,18 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [ + { + rule_id: 'rule-1', + error: { + status_code: 404, + message: '1 connector is missing. Connector id missing is: 123', + }, + }, + ], + action_connectors_warnings: [], }); }); @@ -623,6 +679,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -675,6 +735,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -735,6 +799,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -875,6 +943,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -946,6 +1018,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 0, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1045,6 +1121,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); @@ -1175,6 +1255,10 @@ export default ({ getService }: FtrProviderContext): void => { exceptions_errors: [], exceptions_success: true, exceptions_success_count: 1, + action_connectors_success: true, + action_connectors_success_count: 0, + action_connectors_errors: [], + action_connectors_warnings: [], }); }); }); diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index 1c0323808270d..54435f11a33e1 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { KUBERNETES_TOUR_STORAGE_KEY } from '@kbn/infra-plugin/public/pages/metrics/inventory_view/components/kubernetes_tour'; import { FtrProviderContext } from '../../ftr_provider_context'; import { DATES } from './constants'; @@ -59,10 +60,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.common.navigateToApp('infraOps'); await pageObjects.infraHome.waitForLoading(); }); - after( - async () => - await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs') - ); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + await browser.removeLocalStorageItem(KUBERNETES_TOUR_STORAGE_KEY); + }); it('renders the correct page title', async () => { await pageObjects.header.waitUntilLoadingHasFinished(); @@ -71,6 +72,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(documentTitle).to.contain('Inventory - Infrastructure - Observability - Elastic'); }); + it('renders the kubernetes tour component and allows user to dismiss it without seeing it again', async () => { + await pageObjects.header.waitUntilLoadingHasFinished(); + const kubernetesTourText = + 'Click here to see your infrastructure in different ways, including Kubernetes pods.'; + const ensureKubernetesTourVisible = + await pageObjects.infraHome.ensureKubernetesTourIsVisible(); + + expect(ensureKubernetesTourVisible).to.contain(kubernetesTourText); + + // Persist after refresh + await browser.refresh(); + await pageObjects.infraHome.waitForLoading(); + + expect(ensureKubernetesTourVisible).to.contain(kubernetesTourText); + + await pageObjects.infraHome.clickDismissKubernetesTourButton(); + + await pageObjects.infraHome.ensureKubernetesTourIsClosed(); + }); + it('renders an empty data prompt for dates with no data', async () => { await pageObjects.infraHome.goToTime(DATE_WITHOUT_DATA); await pageObjects.infraHome.getNoMetricsDataPrompt(); diff --git a/x-pack/test/functional/apps/infra/hosts_view.ts b/x-pack/test/functional/apps/infra/hosts_view.ts index 9975a2772a193..224a924b7c1fa 100644 --- a/x-pack/test/functional/apps/infra/hosts_view.ts +++ b/x-pack/test/functional/apps/infra/hosts_view.ts @@ -108,6 +108,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.clickDismissKubernetesTourButton(); await pageObjects.infraHostsView.clickTryHostViewBadge(); }); after(async () => { @@ -126,6 +127,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await loginWithReadOnlyUserAndNavigateToInfra(); + await pageObjects.infraHome.clickDismissKubernetesTourButton(); await pageObjects.infraHostsView.clickTryHostViewBadge(); }); after(async () => { diff --git a/x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_no_users.json b/x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_no_users.json new file mode 100644 index 0000000000000..0ff4e6f213d87 --- /dev/null +++ b/x-pack/test/functional/fixtures/kbn_archiver/cases/8.5.0/cases_no_users.json @@ -0,0 +1,89 @@ +{ + "attributes": { + "assignees": [ + { + "uid": "abc" + } + ], + "closed_at": null, + "closed_by": null, + "connector": { + "fields": [], + "name": "none", + "type": ".none" + }, + "created_at": "2022-08-02T16:56:16.806Z", + "created_by": { + "email": null, + "full_name": null, + "username": null + }, + "description": "a case description", + "duration": null, + "external_service": null, + "owner": "cases", + "settings": { + "syncAlerts": false + }, + "severity": "low", + "status": "open", + "tags": [ + "super", + "awesome" + ], + "title": "Test case", + "updated_at": null, + "updated_by": null + }, + "coreMigrationVersion": "8.5.0", + "id": "163d5820-1284-21ed-81af-63a2bdfb2bf9", + "migrationVersion": { + "cases": "8.5.0" + }, + "references": [], + "type": "cases", + "updated_at": "2022-08-02T16:56:16.808Z", + "version": "WzE3MywxXQ==" +} + +{ + "attributes": { + "action": "delete", + "created_at": "2022-08-02T16:56:17.195Z", + "created_by": { + "email": null, + "full_name": null, + "username": null + }, + "owner": "cases", + "payload":{ + "assignees":[ + { + "uid": "dfg" + } + ] + }, + "type":"assignees" + }, + "coreMigrationVersion": "8.5.0", + "created_at": "2022-08-02T16:56:17.195Z", + "id": "23f6cbd0-a393-11ed-a8c0-432983ba473b", + "migrationVersion": { + "cases-user-actions":"8.5.0" + }, + "references": [ + { + "id": "163d5820-1284-21ed-81af-63a2bdfb2bf9", + "name": "associated-cases", + "type": "cases" + } + ], + "score": null, + "sort":[ + 1675408787725, + 83718 + ], + "type": "cases-user-actions", + "updated_at": "2022-08-02T16:56:17.195Z", + "version": "WzY1OTc2LDNd" +} diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index cb01de3c596e3..a13c9a89ae0cb 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -376,5 +376,19 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide async ensureSuggestionsPanelVisible() { await testSubjects.find('infraSuggestionsPanel'); }, + + async ensureKubernetesTourIsVisible() { + const container = await testSubjects.find('infra-kubernetesTour-text'); + const containerText = await container.getVisibleText(); + return containerText; + }, + + async ensureKubernetesTourIsClosed() { + await testSubjects.missingOrFail('infra-kubernetesTour-text'); + }, + + async clickDismissKubernetesTourButton() { + return await testSubjects.click('infra-kubernetesTour-dismiss'); + }, }; } diff --git a/x-pack/test/functional/services/cases/create.ts b/x-pack/test/functional/services/cases/create.ts index 71aec28374f7a..a1fd58d817e66 100644 --- a/x-pack/test/functional/services/cases/create.ts +++ b/x-pack/test/functional/services/cases/create.ts @@ -119,6 +119,7 @@ export function CasesCreateViewServiceProvider( async createCaseFromModal(params: CreateCaseParams) { await casesCommon.assertCaseModalVisible(true); await testSubjects.click('cases-table-add-case-filter-bar'); + await casesCommon.assertCaseModalVisible(false); await this.creteCaseFromFlyout(params); }, diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index 39713897afaba..1e420cd37368c 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -165,11 +165,11 @@ export function CasesTableServiceProvider( async filterByOwner(owner: string) { await common.clickAndValidate( - 'options-filter-popover-button-Solution', - `options-filter-popover-item-${owner}` + 'solution-filter-popover-button', + `solution-filter-popover-item-${owner}` ); - await testSubjects.click(`options-filter-popover-item-${owner}`); + await testSubjects.click(`solution-filter-popover-item-${owner}`); }, async refreshTable() { diff --git a/x-pack/test/functional_cloud/config.ts b/x-pack/test/functional_cloud/config.ts new file mode 100644 index 0000000000000..db0f19f9c21cc --- /dev/null +++ b/x-pack/test/functional_cloud/config.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile(require.resolve('../functional/config.base.js')); + + const kibanaPort = functionalConfig.get('servers.kibana.port'); + const idpPath = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider/metadata.xml' + ); + const samlIdPPlugin = resolve( + __dirname, + '../security_api_integration/fixtures/saml/saml_provider' + ); + + return { + ...functionalConfig.getAll(), + rootTags: ['skipCloud'], + testFiles: [require.resolve('./tests')], + security: { disableTestUser: true }, + junit: { + reportName: 'Cloud Integrations Functional Tests', + }, + esTestCluster: { + ...functionalConfig.get('esTestCluster'), + serverArgs: [ + ...functionalConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.realms.saml.cloud-saml-kibana.order=0', + `xpack.security.authc.realms.saml.cloud-saml-kibana.idp.metadata.path=${idpPath}`, + 'xpack.security.authc.realms.saml.cloud-saml-kibana.idp.entity_id=http://www.elastic.co/saml1', + `xpack.security.authc.realms.saml.cloud-saml-kibana.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.cloud-saml-kibana.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.cloud-saml-kibana.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.cloud-saml-kibana.attributes.principal=urn:oid:0.0.7', + ], + }, + kbnTestServer: { + ...functionalConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${samlIdPPlugin}`, + '--xpack.cloud.id=ftr_fake_cloud_id', + '--xpack.cloud.base_url=https://cloud.elastic.co', + '--xpack.cloud.deployment_url=/deployments/deploymentId', + '--xpack.cloud.organization_url=/organization/organizationId', + '--xpack.cloud.profile_url=/user/userId', + '--xpack.security.authc.selector.enabled=false', + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { 'cloud-basic': { order: 1 } }, + saml: { 'cloud-saml-kibana': { order: 0, realm: 'cloud-saml-kibana' } }, + })}`, + ], + }, + }; +} diff --git a/x-pack/test/functional_cloud/ftr_provider_context.ts b/x-pack/test/functional_cloud/ftr_provider_context.ts new file mode 100644 index 0000000000000..d6c0afa5ceffd --- /dev/null +++ b/x-pack/test/functional_cloud/ftr_provider_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test'; +import { pageObjects } from '../functional/page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; +export { pageObjects }; diff --git a/x-pack/plugins/transform/common/types/date_picker.ts b/x-pack/test/functional_cloud/services.ts similarity index 68% rename from x-pack/plugins/transform/common/types/date_picker.ts rename to x-pack/test/functional_cloud/services.ts index 09b5d0cba83be..9508ce5eba16d 100644 --- a/x-pack/plugins/transform/common/types/date_picker.ts +++ b/x-pack/test/functional_cloud/services.ts @@ -5,7 +5,6 @@ * 2.0. */ -export interface TimeRangeMs { - from: number; - to: number; -} +import { services as functionalServices } from '../functional/services'; + +export const services = functionalServices; diff --git a/x-pack/test/functional_cloud/test_utils.ts b/x-pack/test/functional_cloud/test_utils.ts new file mode 100644 index 0000000000000..65d0d7ece02c4 --- /dev/null +++ b/x-pack/test/functional_cloud/test_utils.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import Fs from 'fs/promises'; +import Path from 'path'; +import { isEqualWith } from 'lodash'; +import type { Ecs, KibanaExecutionContext } from '@kbn/core/server'; +import type { RetryService } from '@kbn/ftr-common-functional-services'; +import { concatMap, defer, filter, firstValueFrom, ReplaySubject, scan, timeout } from 'rxjs'; + +export const logFilePath = Path.resolve(__dirname, './kibana.log'); +export const ANY = Symbol('any'); + +let logstream$: ReplaySubject | undefined; + +export function getExecutionContextFromLogRecord(record: Ecs | undefined): KibanaExecutionContext { + if (record?.log?.logger !== 'execution_context' || !record?.message) { + throw new Error(`The record is not an entry of execution context`); + } + return JSON.parse(record.message); +} + +export function isExecutionContextLog( + record: Ecs | undefined, + executionContext: KibanaExecutionContext +) { + try { + const object = getExecutionContextFromLogRecord(record); + return isEqualWith(object, executionContext, function customizer(obj1: any, obj2: any) { + if (obj2 === ANY) return true; + }); + } catch (e) { + return false; + } +} + +// to avoid splitting log record containing \n symbol +const endOfLine = /(?<=})\s*\n/; +export async function assertLogContains({ + description, + predicate, + retry, +}: { + description: string; + predicate: (record: Ecs) => boolean; + retry: RetryService; +}): Promise { + // logs are written to disk asynchronously. I sacrificed performance to reduce flakiness. + await retry.waitFor(description, async () => { + if (!logstream$) { + logstream$ = getLogstream$(); + } + try { + await firstValueFrom(logstream$.pipe(filter(predicate), timeout(5_000))); + return true; + } catch (err) { + return false; + } + }); +} + +/** + * Creates an observable that continuously tails the log file. + */ +function getLogstream$(): ReplaySubject { + const stream$ = new ReplaySubject(); + + defer(async function* () { + const fd = await Fs.open(logFilePath, 'rs'); + while (!stream$.isStopped) { + const { bytesRead, buffer } = await fd.read(); + if (bytesRead) { + yield buffer.toString('utf8', 0, bytesRead); + } + } + await fd.close(); + }) + .pipe( + scan( + ({ buffer }, chunk) => { + const logString = buffer.concat(chunk); + const lines = logString.split(endOfLine); + const lastLine = lines.pop(); + const records = lines.map((s) => JSON.parse(s)); + + let leftover = ''; + if (lastLine) { + try { + const validRecord = JSON.parse(lastLine); + records.push(validRecord); + } catch (err) { + leftover = lastLine; + } + } + + return { buffer: leftover, records }; + }, + { + records: [], // The ECS entries in the logs + buffer: '', // Accumulated leftovers from the previous operation + } + ), + concatMap(({ records }) => records) + ) + .subscribe(stream$); + + // let the content start flowing + stream$.subscribe(); + + return stream$; +} + +export function closeLogstream() { + logstream$?.complete(); + logstream$ = undefined; +} + +/** + * Truncates the log file to avoid tests looking at the logs from previous executions. + */ +export async function clearLogFile() { + closeLogstream(); + await Fs.writeFile(logFilePath, '', 'utf8'); + await forceSyncLogFile(); + logstream$ = getLogstream$(); +} + +/** + * Force the completion of all the pending I/O operations in the OS related to the log file. + */ +export async function forceSyncLogFile() { + const fileDescriptor = await Fs.open(logFilePath); + await fileDescriptor.datasync(); + await fileDescriptor.close(); +} diff --git a/x-pack/test/functional_cloud/tests/cloud_links.ts b/x-pack/test/functional_cloud/tests/cloud_links.ts new file mode 100644 index 0000000000000..4eabec139cbe1 --- /dev/null +++ b/x-pack/test/functional_cloud/tests/cloud_links.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'header']); + + describe('Cloud Links integration', function () { + before(async () => { + // Create role mapping so user gets superuser access + await getService('esSupertest') + .post('/_security/role_mapping/cloud-saml-kibana') + .send({ + roles: ['superuser'], + enabled: true, + rules: { field: { 'realm.name': 'cloud-saml-kibana' } }, + }) + .expect(200); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToUrl('home'); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + it('The button "Setup guides" is loaded', async () => { + expect(await find.byCssSelector('[data-test-subj="guideButtonRedirect"]')).to.not.be(null); + const cloudLink = await find.byLinkText('Setup guides'); + expect(cloudLink).to.not.be(null); + }); + + it('"Manage this deployment" is appended to the nav list', async () => { + await PageObjects.common.clickAndValidate('toggleNavButton', 'collapsibleNavCustomNavLink'); + const cloudLink = await find.byLinkText('Manage this deployment'); + expect(cloudLink).to.not.be(null); + }); + + describe('Fills up the user menu items', () => { + it('Shows the button Edit profile', async () => { + await PageObjects.common.clickAndValidate('userMenuButton', 'userMenuLink__Edit profile'); + const cloudLink = await find.byLinkText('Edit profile'); + expect(cloudLink).to.not.be(null); + }); + + it('Shows the button Account & Billing', async () => { + await PageObjects.common.clickAndValidate( + 'userMenuButton', + 'userMenuLink__Account & Billing' + ); + const cloudLink = await find.byLinkText('Account & Billing'); + expect(cloudLink).to.not.be(null); + }); + }); + }); +} diff --git a/x-pack/test/functional_cloud/tests/index.ts b/x-pack/test/functional_cloud/tests/index.ts new file mode 100644 index 0000000000000..c2609c5c2cac3 --- /dev/null +++ b/x-pack/test/functional_cloud/tests/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Cloud Integrations', function () { + loadTestFile(require.resolve('./cloud_links')); + }); +} diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts b/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts index 193b305d707ad..207434635829f 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/attachment_framework.ts @@ -302,11 +302,10 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('renders different solutions', async () => { await openModal(); - await testSubjects.existOrFail('options-filter-popover-button-Solution'); + await testSubjects.existOrFail('solution-filter-popover-button'); - for (const [owner, caseId] of createdCases.entries()) { - await testSubjects.existOrFail(`cases-table-row-${caseId}`); - await testSubjects.existOrFail(`case-table-column-owner-icon-${owner}`); + for (const [, currentCaseId] of createdCases.entries()) { + await testSubjects.existOrFail(`cases-table-row-${currentCaseId}`); } await closeModal(); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 43f96bcd0c429..ef8c79b7b03ca 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -112,5 +112,6 @@ "@kbn/stdio-dev-helpers", "@kbn/alerting-api-integration-helpers", "@kbn/securitysolution-ecs", + "@kbn/cloud-security-posture-plugin", ] } diff --git a/yarn.lock b/yarn.lock index 565676ef9580c..77b529bdd0bcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1456,10 +1456,10 @@ dependencies: object-hash "^1.3.0" -"@elastic/charts@51.3.0": - version "51.3.0" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-51.3.0.tgz#aaf76f380e60fb5e0c944ac6ea333a45f15d7fb2" - integrity sha512-sEeHs99a0IKhKouP0b4rVnMJlwTw7dDvNS5EmTWK8gBsF/GCTrxxoxf1MJtQWvJrOnFEpXrzYvaeiHjmocmZQw== +"@elastic/charts@52.0.0": + version "52.0.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-52.0.0.tgz#db801faaf7fa4334b4cecdbfbfb7c13cf7bb4172" + integrity sha512-PWn1XySpD+2IaWmWftfdvim3l70r9H8y3fAPAnnMDHfzjDTlKJXq/19nMOGRVIwc9FifUN7cSw51shqq8Tj7lA== dependencies: "@popperjs/core" "^2.4.0" bezier-easing "^2.1.0" @@ -1503,6 +1503,14 @@ dependencies: "@elastic/ecs-helpers" "^1.1.0" +"@elastic/elasticsearch-types@npm:@elastic/elasticsearch@^8.2.1": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-8.6.0.tgz#c474f49808deee64b5bc5b8f938bf78f4468cb94" + integrity sha512-mN5EbbgSp1rfRmQ/5Hv7jqAK8xhGJxCg7G84xje8hSefE59P+HPPCv/+DgesCUSJdZpwXIo0DwOWHfHvktxxLw== + dependencies: + "@elastic/transport" "^8.3.1" + tslib "^2.4.0" + "@elastic/elasticsearch@npm:@elastic/elasticsearch-canary@8.6.0-canary.3": version "8.6.0-canary.3" resolved "https://registry.yarnpkg.com/@elastic/elasticsearch-canary/-/elasticsearch-canary-8.6.0-canary.3.tgz#dc518f5ae4bb502b08ff70e4d86fa7b859b3cbe3" @@ -1609,23 +1617,23 @@ resolved "https://registry.yarnpkg.com/@elastic/numeral/-/numeral-2.5.1.tgz#96acf39c3d599950646ef8ccfd24a3f057cf4932" integrity sha512-Tby6TKjixRFY+atVNeYUdGr9m0iaOq8230KTwn8BbUhkh7LwozfgKq0U98HRX7n63ZL62szl+cDKTYzh5WPCFQ== -"@elastic/react-search-ui-views@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@elastic/react-search-ui-views/-/react-search-ui-views-1.14.0.tgz#59e58f5c5f8769995baa0b5606da67e47535ea8b" - integrity sha512-zvW7Dzbf5thNTK1rTKrbsk8bCFK4d4YVtA0VjMSM0Y52CH6zVQrOqEsV/ilcIiHyvR7uFiiLHsFX6Yk3bHdrBw== +"@elastic/react-search-ui-views@1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@elastic/react-search-ui-views/-/react-search-ui-views-1.19.0.tgz#f425144560c89138725b9532a68c562106662555" + integrity sha512-GkDeZl9zwcZR51+kKzAHQ1HzfYXP3nturhU1bKG4vflw5rQgaHuVXfwbjEfrkacyuhdRkx6nIIqULbMbl+kl0g== dependencies: - "@elastic/search-ui" "1.14.0" + "@elastic/search-ui" "1.19.0" downshift "^3.2.10" rc-pagination "^1.20.1" react-select "^5.0.0" -"@elastic/react-search-ui@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@elastic/react-search-ui/-/react-search-ui-1.14.0.tgz#cac7f6bba7cf84bf8c4e8d8deb14a443286c3dfc" - integrity sha512-vwOOpUjL/Y+o/53rt8VeBcjw7rPfg7eZMmSezjw3jYy17eC7mTIHTQf6knbyVO30R1KXl7LyAuyfQLS+ifX2zg== +"@elastic/react-search-ui@^1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@elastic/react-search-ui/-/react-search-ui-1.19.0.tgz#89406226556de54c7665a8ec7be0c3a806e1b4b8" + integrity sha512-WkoSlvGKpCDTJSskclGFX4NejArgbWNk/FhlzrLwM26UqU6Azyr4oMfNTqYh03CBu/8OaiOShQPX+VREdvqBaA== dependencies: - "@elastic/react-search-ui-views" "1.14.0" - "@elastic/search-ui" "1.14.0" + "@elastic/react-search-ui-views" "1.19.0" + "@elastic/search-ui" "1.19.0" "@elastic/request-crypto@2.0.1": version "2.0.1" @@ -1636,18 +1644,26 @@ "@types/node-jose" "1.1.10" node-jose "2.1.0" -"@elastic/search-ui-app-search-connector@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@elastic/search-ui-app-search-connector/-/search-ui-app-search-connector-1.14.0.tgz#5ecd1157394b08f4281b890746450945413d582c" - integrity sha512-VwfbADt+PybgT+l2oK5gfW4WWHsf6al/5uk7qsYXtLneuckcjslUKzjlP5gHqAnV0/UKRXsYjjfb4WRDeo+iAw== +"@elastic/search-ui-app-search-connector@^1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@elastic/search-ui-app-search-connector/-/search-ui-app-search-connector-1.19.0.tgz#bc1db3589ab039a1459953374dafceb50629fb0c" + integrity sha512-ihjGyYCYV9BRNbjsVpBZNCg3SMr73DNhHq2NekTCxMahMS5TbUs88EH0ggWEqfZDxtEyS2coKmKM4UEHZid7kw== dependencies: "@elastic/app-search-javascript" "^8.1.2" - "@elastic/search-ui" "1.14.0" + "@elastic/search-ui" "1.19.0" + +"@elastic/search-ui-engines-connector@^1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@elastic/search-ui-engines-connector/-/search-ui-engines-connector-1.19.0.tgz#1c1256b3b2d3e52d806f3f309f5193ebe7a8f0a1" + integrity sha512-jNoY/j6atD6OD7F3sUD4vSMI/0ehQg058UheW/xkP2qNtB11rOZfBD47Al8tDOF+aKG5/dSxaV2mOfJzY9MzRw== + dependencies: + "@elastic/search-ui" "1.19.0" + "@searchkit/sdk" "^3.0.0" -"@elastic/search-ui@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@elastic/search-ui/-/search-ui-1.14.0.tgz#e5bb2854ffa2571294769ee435d69bda56dc414b" - integrity sha512-6Q+KrE5AhHs20rvWATxv3LKe1u/6t8c/pRV0tJXfsxenFer6d33PSEqPm7uV/paTha+5K8JlayWapwsRRWrVGQ== +"@elastic/search-ui@1.19.0", "@elastic/search-ui@^1.19.0": + version "1.19.0" + resolved "https://registry.yarnpkg.com/@elastic/search-ui/-/search-ui-1.19.0.tgz#4f5b20856061f9500b307679d190accdfaf9f916" + integrity sha512-cShId22JPOkGiyPAmWYjttH/1q8D2Ac14d3lMXfgMNE+98jjWt0Nf/TvrfjLbTpdSVFUaDg/vAVciTRKk1qOVw== dependencies: date-fns "^1.30.1" deep-equal "^1.0.1" @@ -5248,6 +5264,15 @@ dependencies: any-observable "^0.3.0" +"@searchkit/sdk@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@searchkit/sdk/-/sdk-3.0.0.tgz#63fcec0edcba05d241042b8476be1b86d77142e4" + integrity sha512-cWtP5T2+xonPhnwDmda6+RQWvGBg0bigCmZLm+fPPJOymEIug5LQF1s0abcWlwDuXhNnMqiCdk/x2ZUJpt2xTA== + dependencies: + "@elastic/elasticsearch-types" "npm:@elastic/elasticsearch@^8.2.1" + agentkeepalive "^4.1.3" + lodash "^4.17.21" + "@sideway/address@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d" @@ -6249,7 +6274,7 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.24.4.tgz#6fe78777286fdd805ac319c7c743df4935e18ee2" integrity sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA== -"@tanstack/react-query-devtools@^4.23.0": +"@tanstack/react-query-devtools@^4.24.2": version "4.24.4" resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.24.4.tgz#5f0bd45f929950ff2b56a1a3ed18a006780e3495" integrity sha512-4mldcR99QDX8k94I+STM9gPsYF+FDAD2EQJvHtxR2HrDNegbfmY474xuW0QUZaNW/vJi09Gak6b6Vy2INWhL6w== @@ -6258,7 +6283,7 @@ superjson "^1.10.0" use-sync-external-store "^1.2.0" -"@tanstack/react-query@^4.23.0": +"@tanstack/react-query@^4.24.2": version "4.24.4" resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.24.4.tgz#79e892edac33d8aa394795390c0f79e4a8c9be4d" integrity sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q== @@ -22954,7 +22979,7 @@ react-grid-layout@^1.3.4: react-draggable "^4.0.0" react-resizable "^3.0.4" -react-hook-form@^7.42.1: +react-hook-form@^7.43.0: version "7.43.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.0.tgz#d0c19b5c4ec561fbf8d652869ccb513c11c772e7" integrity sha512-/rVEz7T0gLdSFwPqutJ1kn2e0sQNyb9ci/hmwEYr2YG0KF/LSuRLvNrf9QWJM+gj88CjDpDW5Bh/1AD7B2+z9Q== @@ -26150,10 +26175,10 @@ terser@^4.1.2, terser@^4.6.3: source-map "~0.6.1" source-map-support "~0.5.12" -terser@^5.14.1, terser@^5.15.1, terser@^5.3.4, terser@^5.9.0: - version "5.15.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c" - integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw== +terser@^5.14.1, terser@^5.16.1, terser@^5.3.4, terser@^5.9.0: + version "5.16.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.1.tgz#5af3bc3d0f24241c7fb2024199d5c461a1075880" + integrity sha512-xvQfyfA1ayT0qdK47zskQgRZeWLoOQ8JQ6mIgRGVNwZKdQMU+5FkCBjmv4QjcrTzyZquRw2FVtlJSRUmMKQslw== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0"