diff --git a/.github/ISSUE_TEMPLATE/security_solution_bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report_security_solution.md similarity index 91% rename from .github/ISSUE_TEMPLATE/security_solution_bug_report.md rename to .github/ISSUE_TEMPLATE/Bug_report_security_solution.md index bd7d57c72ea569..059e1d267c2862 100644 --- a/.github/ISSUE_TEMPLATE/security_solution_bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report_security_solution.md @@ -2,7 +2,7 @@ name: Bug report for Security Solution about: Help us identify bugs in Elastic Security, SIEM, and Endpoint so we can fix them! title: '[Security Solution]' -labels: Team: SecuritySolution +labels: 'Team: SecuritySolution' --- **Describe the bug:** diff --git a/docs/api/dashboard/export-dashboard.asciidoc b/docs/api/dashboard/export-dashboard.asciidoc index 2099fb599ba67c..d33b9603fae73c 100644 --- a/docs/api/dashboard/export-dashboard.asciidoc +++ b/docs/api/dashboard/export-dashboard.asciidoc @@ -11,6 +11,8 @@ experimental[] Export dashboards and corresponding saved objects. `GET :/api/kibana/dashboards/export` +`GET :/s//api/kibana/dashboards/export` + [[dashboard-api-export-params]] ==== Query parameters diff --git a/docs/api/dashboard/import-dashboard.asciidoc b/docs/api/dashboard/import-dashboard.asciidoc index 56bd4abbc80236..5d1fab41a2a140 100644 --- a/docs/api/dashboard/import-dashboard.asciidoc +++ b/docs/api/dashboard/import-dashboard.asciidoc @@ -11,6 +11,8 @@ experimental[] Import dashboards and corresponding saved objects. `POST :/api/kibana/dashboards/import` +`POST :/s//api/kibana/dashboards/import` + [[dashboard-api-import-params]] ==== Query parameters diff --git a/docs/api/features.asciidoc b/docs/api/features.asciidoc index 57a87ff6342f9a..dad3ef75c81176 100644 --- a/docs/api/features.asciidoc +++ b/docs/api/features.asciidoc @@ -28,8 +28,6 @@ The API returns the following: { "id": "discover", "name": "Discover", - "icon": "discoverApp", - "navLinkId": "discover", "app": [ "kibana" ], @@ -73,8 +71,6 @@ The API returns the following: { "id": "visualize", "name": "Visualize", - "icon": "visualizeApp", - "navLinkId": "visualize", "app": [ "kibana" ], @@ -120,8 +116,6 @@ The API returns the following: { "id": "dashboard", "name": "Dashboard", - "icon": "dashboardApp", - "navLinkId": "dashboards", "app": [ "kibana" ], @@ -172,8 +166,6 @@ The API returns the following: { "id": "dev_tools", "name": "Dev Tools", - "icon": "devToolsApp", - "navLinkId": "dev_tools", "app": [ "kibana" ], diff --git a/docs/apm/apm-alerts.asciidoc b/docs/apm/apm-alerts.asciidoc index bc5e1ccc1dd55e..7bdfe80b421777 100644 --- a/docs/apm/apm-alerts.asciidoc +++ b/docs/apm/apm-alerts.asciidoc @@ -18,12 +18,22 @@ image::apm/images/apm-alert.png[Create an alert in the APM app] For a walkthrough of the alert flyout panel, including detailed information on each configurable property, see Kibana's <>. -The APM app supports two different types of threshold alerts: transaction duration, and error rate. -Below, we'll create one of each. +The APM app supports four different types of alerts: + +* Transaction duration anomaly: +alerts when the service's transaction duration reaches a certain anomaly score +* Transaction duration threshold: +alerts when the service's transaction duration exceeds a given time limit over a given time frame +* Transaction error rate threshold: +alerts when the service's transaction error rate is above the selected rate over a given time frame +* Error count threshold: +alerts when service exceeds a selected number of errors over a given time frame + +Below, we'll walk through the creation of two of these alerts. [float] [[apm-create-transaction-alert]] -=== Create a transaction duration alert +=== Example: create a transaction duration alert Transaction duration alerts trigger when the duration of a specific transaction type in a service exceeds a defined threshold. This guide will create an alert for the `opbeans-java` service based on the following criteria: @@ -57,9 +67,9 @@ Enter a name for the connector, and paste the webhook URL. See Slack's webhook documentation if you need to create one. -Add a message body in markdown format. +A default message is provided as a starting point for your alert. You can use the https://mustache.github.io/[Mustache] template syntax, i.e., `{{variable}}` -to pass alert values at the time a condition is detected to an action. +to pass additional alert values at the time a condition is detected to an action. A list of available variables can be accessed by selecting the **add variable** button image:apm/images/add-variable.png[add variable button]. @@ -67,7 +77,7 @@ Select **Save**. The alert has been created and is now active! [float] [[apm-create-error-alert]] -=== Create an error rate alert +=== Example: create an error rate alert Error rate alerts trigger when the number of errors in a service exceeds a defined threshold. This guide creates an alert for the `opbeans-python` service based on the following criteria: @@ -94,9 +104,9 @@ Based on the alert criteria, define the following alert details: Select the **Email** action type and click **Create a connector**. Fill out the required details: sender, host, port, etc., and click **save**. -Add a message body in markdown format. +A default message is provided as a starting point for your alert. You can use the https://mustache.github.io/[Mustache] template syntax, i.e., `{{variable}}` -to pass alert values at the time a condition is detected to an action. +to pass additional alert values at the time a condition is detected to an action. A list of available variables can be accessed by selecting the **add variable** button image:apm/images/add-variable.png[add variable button]. diff --git a/docs/apm/filters.asciidoc b/docs/apm/filters.asciidoc index d53adb439f0c8e..c405ea10ade3d2 100644 --- a/docs/apm/filters.asciidoc +++ b/docs/apm/filters.asciidoc @@ -69,7 +69,7 @@ the host filter will still be applied. These filters are very useful for quickly and easily removing noise from your data. With just a click, you can filter your transactions by the transaction result, -host, container ID, and more. +host, container ID, Kubernetes pod, and more. [role="screenshot"] image::apm/images/local-filter.png[Local filters available in the APM app in Kibana] \ No newline at end of file diff --git a/docs/apm/images/apm-alert.png b/docs/apm/images/apm-alert.png index 350704d8969ae6..c68b36f522bfc0 100644 Binary files a/docs/apm/images/apm-alert.png and b/docs/apm/images/apm-alert.png differ diff --git a/docs/apm/images/apm-distributed-tracing.png b/docs/apm/images/apm-distributed-tracing.png index e9c6713361c731..0dbffa591d43aa 100644 Binary files a/docs/apm/images/apm-distributed-tracing.png and b/docs/apm/images/apm-distributed-tracing.png differ diff --git a/docs/apm/images/apm-error-group.png b/docs/apm/images/apm-error-group.png index ecdf9c20cf4aa3..359bdc6b704e94 100644 Binary files a/docs/apm/images/apm-error-group.png and b/docs/apm/images/apm-error-group.png differ diff --git a/docs/apm/images/apm-errors-overview.png b/docs/apm/images/apm-errors-overview.png index 90f16b81e9f50e..969a1f19f9f436 100644 Binary files a/docs/apm/images/apm-errors-overview.png and b/docs/apm/images/apm-errors-overview.png differ diff --git a/docs/apm/images/apm-geo-ui.png b/docs/apm/images/apm-geo-ui.png index a767ed7e08e0cf..3757127bad9c09 100644 Binary files a/docs/apm/images/apm-geo-ui.png and b/docs/apm/images/apm-geo-ui.png differ diff --git a/docs/apm/images/apm-metrics.png b/docs/apm/images/apm-metrics.png index 60383ef428f2a2..ffe5ffc7e1d83f 100644 Binary files a/docs/apm/images/apm-metrics.png and b/docs/apm/images/apm-metrics.png differ diff --git a/docs/apm/images/apm-query-bar.png b/docs/apm/images/apm-query-bar.png index 313ee7d4b8fc83..90955fb61016da 100644 Binary files a/docs/apm/images/apm-query-bar.png and b/docs/apm/images/apm-query-bar.png differ diff --git a/docs/apm/images/apm-service-map-anomaly.png b/docs/apm/images/apm-service-map-anomaly.png index b661e8f09d1a12..cd59f86690666a 100644 Binary files a/docs/apm/images/apm-service-map-anomaly.png and b/docs/apm/images/apm-service-map-anomaly.png differ diff --git a/docs/apm/images/apm-services-overview.png b/docs/apm/images/apm-services-overview.png index 48236522ddfbb8..85d14cc7dfc6e6 100644 Binary files a/docs/apm/images/apm-services-overview.png and b/docs/apm/images/apm-services-overview.png differ diff --git a/docs/apm/images/apm-settings.png b/docs/apm/images/apm-settings.png index 4eaef9ec15ac53..14cf32877b7201 100644 Binary files a/docs/apm/images/apm-settings.png and b/docs/apm/images/apm-settings.png differ diff --git a/docs/apm/images/apm-traces.png b/docs/apm/images/apm-traces.png index 6219be5b6d6e49..bf1f7e783bb110 100644 Binary files a/docs/apm/images/apm-traces.png and b/docs/apm/images/apm-traces.png differ diff --git a/docs/apm/images/apm-transaction-response-dist.png b/docs/apm/images/apm-transaction-response-dist.png index ecf5a4af2c25df..1d268bbaac465c 100644 Binary files a/docs/apm/images/apm-transaction-response-dist.png and b/docs/apm/images/apm-transaction-response-dist.png differ diff --git a/docs/apm/images/apm-transaction-sample.png b/docs/apm/images/apm-transaction-sample.png index 73668b094f9cf4..bfdb6a5abe65bc 100644 Binary files a/docs/apm/images/apm-transaction-sample.png and b/docs/apm/images/apm-transaction-sample.png differ diff --git a/docs/apm/images/apm-transactions-overview.png b/docs/apm/images/apm-transactions-overview.png index b3b6ca22c4f635..53d7637b18647c 100644 Binary files a/docs/apm/images/apm-transactions-overview.png and b/docs/apm/images/apm-transactions-overview.png differ diff --git a/docs/apm/images/example-metadata.png b/docs/apm/images/example-metadata.png index 0e35f906917235..2a5bda7f088f69 100644 Binary files a/docs/apm/images/example-metadata.png and b/docs/apm/images/example-metadata.png differ diff --git a/docs/apm/images/jvm-metrics-overview.png b/docs/apm/images/jvm-metrics-overview.png index 9c8ba4a12a2629..586836c6cfe3eb 100644 Binary files a/docs/apm/images/jvm-metrics-overview.png and b/docs/apm/images/jvm-metrics-overview.png differ diff --git a/docs/apm/images/jvm-metrics.png b/docs/apm/images/jvm-metrics.png index 1720e1370ff901..52a1ca5bea8d87 100644 Binary files a/docs/apm/images/jvm-metrics.png and b/docs/apm/images/jvm-metrics.png differ diff --git a/docs/apm/images/local-filter.png b/docs/apm/images/local-filter.png index faac5c143a7d83..8657e39f430aa0 100644 Binary files a/docs/apm/images/local-filter.png and b/docs/apm/images/local-filter.png differ diff --git a/docs/apm/images/service-maps-java.png b/docs/apm/images/service-maps-java.png index e1a42f4c76e12f..b3726bdc00ab68 100644 Binary files a/docs/apm/images/service-maps-java.png and b/docs/apm/images/service-maps-java.png differ diff --git a/docs/apm/images/service-maps.png b/docs/apm/images/service-maps.png index 078fabcfa2879c..878a31adc69cae 100644 Binary files a/docs/apm/images/service-maps.png and b/docs/apm/images/service-maps.png differ diff --git a/docs/apm/images/service-quick-health.png b/docs/apm/images/service-quick-health.png new file mode 100644 index 00000000000000..aab13325130797 Binary files /dev/null and b/docs/apm/images/service-quick-health.png differ diff --git a/docs/apm/images/specific-transaction.png b/docs/apm/images/specific-transaction.png index 9911dbd879f412..52073bf76520a4 100644 Binary files a/docs/apm/images/specific-transaction.png and b/docs/apm/images/specific-transaction.png differ diff --git a/docs/apm/machine-learning.asciidoc b/docs/apm/machine-learning.asciidoc index db2a1ef6e2da08..b31d717a6932e8 100644 --- a/docs/apm/machine-learning.asciidoc +++ b/docs/apm/machine-learning.asciidoc @@ -14,7 +14,12 @@ Machine learning jobs are created per environment, and are based on a service's Because jobs are created at the environment level, you can add new services to your existing environments without the need for additional machine learning jobs. -After a machine learning job is created, results are shown in two places: +Results from machine learning jobs are shown in multiple places throughout the APM app: + +* The **Services overview** provides a quick-glance view of the general health of all of your services. ++ +[role="screenshot"] +image::apm/images/service-quick-health.png[Example view of anomaly scores on response times in the APM app] * The transaction duration chart will show the expected bounds and add an annotation when the anomaly score is 75 or above. + diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index d629a95073a745..d44c4ff6caa5cb 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -33,7 +33,7 @@ distributed tracing will not work, and the connection will not be drawn on the m Select the **Service Map** tab to get started. By default, all instrumented services and connections are shown. Whether you're onboarding a new engineer, or just trying to grasp the big picture, -click around, zoom in and out, and begin to visualize how your services are connected. +drag things around, zoom in and out, and begin to visualize how your services are connected. If there's a specific service that interests you, select that service to highlight its connections. Clicking **Focus map** will refocus the map on that specific service and lock the connection highlighting. diff --git a/docs/apm/services.asciidoc b/docs/apm/services.asciidoc index 395e23c3793064..2bf2e35c21cd82 100644 --- a/docs/apm/services.asciidoc +++ b/docs/apm/services.asciidoc @@ -2,8 +2,13 @@ [[services]] === Services overview -The *Services* overview gives you quick insights into the health and general performance of all of your instrumented services. -Services are sorted by the `service.name` configured in each of the {apm-agents-ref}[APM agents] you’ve installed. +The *Services* overview page provides a quick, high-level overview of the health and general +performance of all instrumented services. + +To help surface potential issues, services are sorted by their health status: +**critical** > **warning** > **healthy** > **unknown**. +Health status is powered by machine learning and requires anomaly detection to be enabled. +Learn more in <>. [role="screenshot"] -image::apm/images/apm-services-overview.png[Example view of services table the APM app in Kibana] \ No newline at end of file +image::apm/images/apm-services-overview.png[Example view of services table the APM app in Kibana] diff --git a/docs/apm/spans.asciidoc b/docs/apm/spans.asciidoc index c35fb115d2db44..7f29b1f003f1cf 100644 --- a/docs/apm/spans.asciidoc +++ b/docs/apm/spans.asciidoc @@ -3,7 +3,7 @@ === Trace sample timeline The trace sample timeline visualization is a bird's-eye view of what your application was doing while it was trying to respond to a request. -This makes it useful for visualizing where the selected transaction spent most of its time. +This makes it useful for visualizing where a selected transaction spent most of its time. [role="screenshot"] image::apm/images/apm-transaction-sample.png[Example of distributed trace colors in the APM app in Kibana] @@ -43,9 +43,12 @@ this makes finding possible bottlenecks throughout your application much easier image::apm/images/apm-distributed-tracing.png[Example view of the distributed tracing in APM app in Kibana] Don't forget; by definition, a distributed trace includes more than one transaction. -When viewing these distributed traces in the timeline waterfall, you'll see this image:apm/images/transaction-icon.png[APM icon] icon, +When viewing distributed traces in the timeline waterfall, +you'll see this icon: image:apm/images/transaction-icon.png[APM icon], which indicates the next transaction in the trace. -These transactions can be expanded and viewed in detail by clicking on them. +For easier problem isolation, transactions can be collapsed in the waterfall by clicking +the icon to the left of the transactions. +Transactions can also be expanded and viewed in detail by clicking on them. After exploring these traces, you can return to the full trace by clicking *View full trace*. diff --git a/docs/apm/traces.asciidoc b/docs/apm/traces.asciidoc index 52b4b618de4663..3bafebd7331597 100644 --- a/docs/apm/traces.asciidoc +++ b/docs/apm/traces.asciidoc @@ -7,7 +7,8 @@ and which services were part of it. In addition to the Traces overview, you can view your application traces in the <>. The *Traces* overview displays the entry transaction for all traces in your application. -If you're using <>, this view is key to finding the critical paths within your application. +If you're using <>, +this view is key to finding the critical paths within your application. Transactions with the same name are grouped together and only shown once in this table. By default, transactions are sorted by _Impact_. diff --git a/docs/apm/transactions.asciidoc b/docs/apm/transactions.asciidoc index 84ab6b2a58579d..fef98a86de1d01 100644 --- a/docs/apm/transactions.asciidoc +++ b/docs/apm/transactions.asciidoc @@ -10,17 +10,8 @@ Selecting a <> brings you to the *transactions* overview. [role="screenshot"] image::apm/images/apm-transactions-overview.png[Example view of transactions table in the APM app in Kibana] -The *time spent by span type*, *transaction duration*, and *requests per minute* chart display information on all transactions associated with the selected service: - -*Time spent by span type*:: -Visualize where your application is spending most of its time. -For example, is your app spending time in external calls, database processing, or application code execution? -+ -The time a transaction took to complete is also recorded and displayed on the chart under the "app" label. -"app" indicates that something was happening within the application, but we're not sure exactly what. -This could be a sign that the agent does not have auto-instrumentation for whatever was happening during that time. -+ -It's important to note that if you have asynchronous spans, the sum of all span times may exceed the duration of the transaction. +The *transaction duration*, *transactions per minute*, *transaction error rate*, and *time spent by span type* +charts display information on all transactions associated with the selected service: *Transaction duration*:: Response times for this service, broken down into average, 95th, and 99th percentile. @@ -28,11 +19,26 @@ If there's a weird spike that you'd like to investigate, you can simply zoom in on the graph - this will adjust the specific time range, and all of the data on the page will update accordingly. -*Requests per minute*:: +*Transactions per minute*:: Visualize response codes: `2xx`, `3xx`, `4xx`, etc., and is useful for determining if you're serving more of one code than you typically do. Like in the Transaction duration graph, you can zoom in on anomalies to further investigate them. +*Transaction error rate*:: +Visualize the total number of transactions with errors divided by the total number of transactions. +Any unexpected increases, decreases, or irregular patterns can be investigated further +with the <>. + +*Time spent by span type*:: +Visualize where your application is spending most of its time. +For example, is your app spending time in external calls, database processing, or application code execution? ++ +The time a transaction took to complete is also recorded and displayed on the chart under the "app" label. +"app" indicates that something was happening within the application, but we're not sure exactly what. +This could be a sign that the agent does not have auto-instrumentation for whatever was happening during that time. ++ +It's important to note that if you have asynchronous spans, the sum of all span times may exceed the duration of the transaction. + [[transactions-table]] ==== Transactions table @@ -61,42 +67,45 @@ refer to the documentation for each {apm-agents-ref}[APM Agent] you've implement ==== RUM Transaction overview The transaction overview page is customized for the JavaScript RUM Agent. -This page highlights things like *page load times*, *transactions per minute*, and even the *average page load duration distribution by country*. +Specifically, the page highlights *page load times* for your service: [role="screenshot"] image::apm/images/apm-geo-ui.png[average page load duration distribution] -This data is available due to the geo-ip and user agent pipelines being enabled by default, -which allows for the capture of geo-location and user agent data. -These visualizations make it easy for you to visualize performance information about your -end-users' experience based on their location. +Additional RUM goodies, like core vitals, and visitor breakdown by browser, location, and device, +are available in the Observability User Experience tab. +// To do +// Add link to the Observability UE docs when complete [[transaction-details]] ==== Transaction details Selecting a transaction group will bring you to the *transaction* details. -Transaction details include a high-level overview of the time spent by span type, -transaction group duration, requests per minute, and transaction group duration distribution. -It's important to note that all of these graphs show data from every transaction within the selected transaction group. +This page is visually similar to the transaction overview, but it shows data from all transactions within +the selected transaction group. [role="screenshot"] image::apm/images/apm-transaction-response-dist.png[Example view of response time distribution] Up to ten sampled transactions are also displayed. -These sampled transactions are based on your selection in the *Transactions duration distribution*. -You can update the sampled transactions by selecting a new _bucket_ in the transactions duration distribution graph. -The number of requests per bucket is displayed when hovering over the graph, and the selected bucket is highlighted to stand out. +These sampled transactions are based on the _bucket_ selection in the *Transactions duration distribution* chart. +You can update the sampled transactions by selecting a new _bucket_. +The number of requests per bucket is displayed when hovering over the graph, +and the selected bucket is highlighted to stand out. + +The screenshot below shows a typical distribution, and indicates most of our requests were served quickly--awesome! +It's the requests on the right, the ones taking longer than average, that we probably want to focus on. [role="screenshot"] image::apm/images/apm-transaction-duration-dist.png[Example view of transactions duration distribution graph] -This graph shows a typical distribution, and indicates most of our requests were served quickly--awesome! -It's the requests on the right, the ones taking longer than average, that we probably want to focus on. - -When you select one of these buckets, +When you select a bucket, you're presented with up to ten trace samples. -Each sample has a trace timeline waterfall that shows what a typical request in that bucket was doing. -By investigating this timeline waterfall, we can hopefully determine _why_ this request was slow and then implement a fix. +Each sample has a trace timeline waterfall that shows how a typical request in that bucket executed. +This waterfall is useful for understanding the parent/child hierarchy of transactions and spans, +and ultimately determining _why_ a request was slow. +For large waterfalls, expand problematic transactions and collapse well-performing ones +for easier problem isolation and troubleshooting. [role="screenshot"] image::apm/images/apm-transaction-sample.png[Example view of transactions sample] diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index b4c9c6a4ec39e4..6c52c021fc0fcc 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -14,6 +14,7 @@ Also, check out the https://discuss.elastic.co/c/apm[APM discussion forum]. * <> * <> * <> +* <> [float] [[no-apm-data-found]] @@ -180,3 +181,19 @@ setup.template.append_fields: type: object dynamic: true ---- + +[float] +[[service-map-rum-connections]] +=== Service maps: no connection between client and server + +If the service map is not showing an expected connection between the client and server, +it's likely because you haven't configured +{apm-agent-rum}/configuration.html#distributed-tracing-origins[`distributedTracingOrigins`]. + + +This setting is necessary, for example, for cross-origin requests. +If you have a basic web application that provides data via an API on `localhost:4000`, +and serves HTML from `localhost:4001`, you'd need to set `distributedTracingOrigins: ['https://localhost:4000']` +to ensure the origin is monitored as a part of distributed tracing. +In other words, `distributedTracingOrigins` is consulted prior to the agent adding the +distributed tracing `traceparent` header to each request. diff --git a/docs/developer/architecture/security/feature-registration.asciidoc b/docs/developer/architecture/security/feature-registration.asciidoc index b27e457940d933..8c80c2e5f2ffb4 100644 --- a/docs/developer/architecture/security/feature-registration.asciidoc +++ b/docs/developer/architecture/security/feature-registration.asciidoc @@ -59,15 +59,6 @@ of features within the management screens. |See <> |The set of subfeatures that enables finer access control than the `all` and `read` feature privileges. These options are only available in the Gold subscription level and higher. -|`icon` -|`string` -|"discoverApp" -|An https://elastic.github.io/eui/#/display/icons[EUI Icon] to use for this feature. - -|`navLinkId` -|`string` -|"sample_app" -|The ID of the navigation link associated with your feature. |=== ==== Privilege definition @@ -100,8 +91,6 @@ public setup(core, { features }) { features.registerKibanaFeature({ id: 'canvas', name: 'Canvas', - icon: 'canvasApp', - navLinkId: 'canvas', category: DEFAULT_APP_CATEGORIES.kibana, app: ['canvas', 'kibana'], catalogue: ['canvas'], @@ -160,8 +149,6 @@ public setup(core, { features }) { name: i18n.translate('xpack.features.devToolsFeatureName', { defaultMessage: 'Dev Tools', }), - icon: 'devToolsApp', - navLinkId: 'dev_tools', category: DEFAULT_APP_CATEGORIES.management, app: ['kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], @@ -223,8 +210,6 @@ public setup(core, { features }) { defaultMessage: 'Discover', }), order: 100, - icon: 'discoverApp', - navLinkId: 'discover', category: DEFAULT_APP_CATEGORIES.kibana, app: ['kibana'], catalogue: ['discover'], diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md index c5e01715534d1c..ad762cae489c87 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.es_field_types.md @@ -43,4 +43,5 @@ export declare enum ES_FIELD_TYPES | STRING | "string" | | | TEXT | "text" | | | TOKEN\_COUNT | "token_count" | | +| UNSIGNED\_LONG | "unsigned_long" | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.deletefieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.deletefieldformat.md new file mode 100644 index 00000000000000..26276a809a6131 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.deletefieldformat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [deleteFieldFormat](./kibana-plugin-plugins-data-public.indexpattern.deletefieldformat.md) + +## IndexPattern.deleteFieldFormat property + +Signature: + +```typescript +deleteFieldFormat: (fieldName: string) => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getformatterforfieldnodefault.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getformatterforfieldnodefault.md new file mode 100644 index 00000000000000..0dd171108b20b3 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.getformatterforfieldnodefault.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [getFormatterForFieldNoDefault](./kibana-plugin-plugins-data-public.indexpattern.getformatterforfieldnodefault.md) + +## IndexPattern.getFormatterForFieldNoDefault() method + +Get formatter for a given field name. Return undefined if none exists + +Signature: + +```typescript +getFormatterForFieldNoDefault(fieldname: string): FieldFormat | undefined; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| fieldname | string | | + +Returns: + +`FieldFormat | undefined` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index c07041470d102e..7e3192481dfffa 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -20,6 +20,7 @@ export declare class IndexPattern implements IIndexPattern | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [deleteFieldFormat](./kibana-plugin-plugins-data-public.indexpattern.deletefieldformat.md) | | (fieldName: string) => void | | | [fieldFormatMap](./kibana-plugin-plugins-data-public.indexpattern.fieldformatmap.md) | | Record<string, any> | | | [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | IIndexPatternFieldList & {
toSpec: () => IndexPatternFieldMap;
} | | | [flattenHit](./kibana-plugin-plugins-data-public.indexpattern.flattenhit.md) | | (hit: Record<string, any>, deep?: boolean) => Record<string, any> | | @@ -30,6 +31,7 @@ export declare class IndexPattern implements IIndexPattern | [intervalName](./kibana-plugin-plugins-data-public.indexpattern.intervalname.md) | | string | undefined | | | [metaFields](./kibana-plugin-plugins-data-public.indexpattern.metafields.md) | | string[] | | | [resetOriginalSavedObjectBody](./kibana-plugin-plugins-data-public.indexpattern.resetoriginalsavedobjectbody.md) | | () => void | Reset last saved saved object fields. used after saving | +| [setFieldFormat](./kibana-plugin-plugins-data-public.indexpattern.setfieldformat.md) | | (fieldName: string, format: SerializedFieldFormat) => void | | | [sourceFilters](./kibana-plugin-plugins-data-public.indexpattern.sourcefilters.md) | | SourceFilter[] | | | [timeFieldName](./kibana-plugin-plugins-data-public.indexpattern.timefieldname.md) | | string | undefined | | | [title](./kibana-plugin-plugins-data-public.indexpattern.title.md) | | string | | @@ -47,6 +49,7 @@ export declare class IndexPattern implements IIndexPattern | [getComputedFields()](./kibana-plugin-plugins-data-public.indexpattern.getcomputedfields.md) | | | | [getFieldByName(name)](./kibana-plugin-plugins-data-public.indexpattern.getfieldbyname.md) | | | | [getFormatterForField(field)](./kibana-plugin-plugins-data-public.indexpattern.getformatterforfield.md) | | Provide a field, get its formatter | +| [getFormatterForFieldNoDefault(fieldname)](./kibana-plugin-plugins-data-public.indexpattern.getformatterforfieldnodefault.md) | | Get formatter for a given field name. Return undefined if none exists | | [getNonScriptedFields()](./kibana-plugin-plugins-data-public.indexpattern.getnonscriptedfields.md) | | | | [getScriptedFields()](./kibana-plugin-plugins-data-public.indexpattern.getscriptedfields.md) | | | | [getSourceFiltering()](./kibana-plugin-plugins-data-public.indexpattern.getsourcefiltering.md) | | Get the source filtering configuration for that index. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.setfieldformat.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.setfieldformat.md new file mode 100644 index 00000000000000..9774fc8c7308cf --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.setfieldformat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) > [setFieldFormat](./kibana-plugin-plugins-data-public.indexpattern.setfieldformat.md) + +## IndexPattern.setFieldFormat property + +Signature: + +```typescript +setFieldFormat: (fieldName: string, format: SerializedFieldFormat) => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.fieldformats.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.fieldformats.md new file mode 100644 index 00000000000000..af4115e4c4e094 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.fieldformats.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternSpec](./kibana-plugin-plugins-data-public.indexpatternspec.md) > [fieldFormats](./kibana-plugin-plugins-data-public.indexpatternspec.fieldformats.md) + +## IndexPatternSpec.fieldFormats property + +Signature: + +```typescript +fieldFormats?: Record; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md index 74c4df126e1bf6..f3b692209ca671 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternspec.md @@ -14,6 +14,7 @@ export interface IndexPatternSpec | Property | Type | Description | | --- | --- | --- | +| [fieldFormats](./kibana-plugin-plugins-data-public.indexpatternspec.fieldformats.md) | Record<string, SerializedFieldFormat> | | | [fields](./kibana-plugin-plugins-data-public.indexpatternspec.fields.md) | IndexPatternFieldMap | | | [id](./kibana-plugin-plugins-data-public.indexpatternspec.id.md) | string | | | [intervalName](./kibana-plugin-plugins-data-public.indexpatternspec.intervalname.md) | string | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md index d071955f4f522d..545b7b9d27e10d 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.es_field_types.md @@ -43,4 +43,5 @@ export declare enum ES_FIELD_TYPES | STRING | "string" | | | TEXT | "text" | | | TOKEN\_COUNT | "token_count" | | +| UNSIGNED\_LONG | "unsigned_long" | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.deletefieldformat.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.deletefieldformat.md new file mode 100644 index 00000000000000..4bfda565274741 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.deletefieldformat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPattern](./kibana-plugin-plugins-data-server.indexpattern.md) > [deleteFieldFormat](./kibana-plugin-plugins-data-server.indexpattern.deletefieldformat.md) + +## IndexPattern.deleteFieldFormat property + +Signature: + +```typescript +deleteFieldFormat: (fieldName: string) => void; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getformatterforfieldnodefault.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getformatterforfieldnodefault.md new file mode 100644 index 00000000000000..77cc879e2f2f2f --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.getformatterforfieldnodefault.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPattern](./kibana-plugin-plugins-data-server.indexpattern.md) > [getFormatterForFieldNoDefault](./kibana-plugin-plugins-data-server.indexpattern.getformatterforfieldnodefault.md) + +## IndexPattern.getFormatterForFieldNoDefault() method + +Get formatter for a given field name. Return undefined if none exists + +Signature: + +```typescript +getFormatterForFieldNoDefault(fieldname: string): FieldFormat | undefined; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| fieldname | string | | + +Returns: + +`FieldFormat | undefined` + diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md index 603864234d34bb..2e15c8d3867eca 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.md @@ -20,6 +20,7 @@ export declare class IndexPattern implements IIndexPattern | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [deleteFieldFormat](./kibana-plugin-plugins-data-server.indexpattern.deletefieldformat.md) | | (fieldName: string) => void | | | [fieldFormatMap](./kibana-plugin-plugins-data-server.indexpattern.fieldformatmap.md) | | Record<string, any> | | | [fields](./kibana-plugin-plugins-data-server.indexpattern.fields.md) | | IIndexPatternFieldList & {
toSpec: () => IndexPatternFieldMap;
} | | | [flattenHit](./kibana-plugin-plugins-data-server.indexpattern.flattenhit.md) | | (hit: Record<string, any>, deep?: boolean) => Record<string, any> | | @@ -30,6 +31,7 @@ export declare class IndexPattern implements IIndexPattern | [intervalName](./kibana-plugin-plugins-data-server.indexpattern.intervalname.md) | | string | undefined | | | [metaFields](./kibana-plugin-plugins-data-server.indexpattern.metafields.md) | | string[] | | | [resetOriginalSavedObjectBody](./kibana-plugin-plugins-data-server.indexpattern.resetoriginalsavedobjectbody.md) | | () => void | Reset last saved saved object fields. used after saving | +| [setFieldFormat](./kibana-plugin-plugins-data-server.indexpattern.setfieldformat.md) | | (fieldName: string, format: SerializedFieldFormat) => void | | | [sourceFilters](./kibana-plugin-plugins-data-server.indexpattern.sourcefilters.md) | | SourceFilter[] | | | [timeFieldName](./kibana-plugin-plugins-data-server.indexpattern.timefieldname.md) | | string | undefined | | | [title](./kibana-plugin-plugins-data-server.indexpattern.title.md) | | string | | @@ -47,6 +49,7 @@ export declare class IndexPattern implements IIndexPattern | [getComputedFields()](./kibana-plugin-plugins-data-server.indexpattern.getcomputedfields.md) | | | | [getFieldByName(name)](./kibana-plugin-plugins-data-server.indexpattern.getfieldbyname.md) | | | | [getFormatterForField(field)](./kibana-plugin-plugins-data-server.indexpattern.getformatterforfield.md) | | Provide a field, get its formatter | +| [getFormatterForFieldNoDefault(fieldname)](./kibana-plugin-plugins-data-server.indexpattern.getformatterforfieldnodefault.md) | | Get formatter for a given field name. Return undefined if none exists | | [getNonScriptedFields()](./kibana-plugin-plugins-data-server.indexpattern.getnonscriptedfields.md) | | | | [getScriptedFields()](./kibana-plugin-plugins-data-server.indexpattern.getscriptedfields.md) | | | | [getSourceFiltering()](./kibana-plugin-plugins-data-server.indexpattern.getsourcefiltering.md) | | Get the source filtering configuration for that index. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.setfieldformat.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.setfieldformat.md new file mode 100644 index 00000000000000..a8f2e726dd9b31 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpattern.setfieldformat.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IndexPattern](./kibana-plugin-plugins-data-server.indexpattern.md) > [setFieldFormat](./kibana-plugin-plugins-data-server.indexpattern.setfieldformat.md) + +## IndexPattern.setFieldFormat property + +Signature: + +```typescript +setFieldFormat: (fieldName: string, format: SerializedFieldFormat) => void; +``` diff --git a/docs/management/images/management-index-templates-mappings.png b/docs/management/images/management-index-templates-mappings.png old mode 100755 new mode 100644 index 62321fc0e46660..beb964b348171f Binary files a/docs/management/images/management-index-templates-mappings.png and b/docs/management/images/management-index-templates-mappings.png differ diff --git a/docs/management/images/management-index-templates.png b/docs/management/images/management-index-templates.png old mode 100755 new mode 100644 index 6f2564af72b5ca..07f1fb9a7add11 Binary files a/docs/management/images/management-index-templates.png and b/docs/management/images/management-index-templates.png differ diff --git a/docs/management/images/management_index_component_template.png b/docs/management/images/management_index_component_template.png new file mode 100644 index 00000000000000..c03029fd172f05 Binary files /dev/null and b/docs/management/images/management_index_component_template.png differ diff --git a/docs/management/images/management_index_create_wizard.png b/docs/management/images/management_index_create_wizard.png old mode 100755 new mode 100644 index b18c36366be94d..bff1dd4cd0e7ab Binary files a/docs/management/images/management_index_create_wizard.png and b/docs/management/images/management_index_create_wizard.png differ diff --git a/docs/management/images/management_index_data_stream_backing_index.png b/docs/management/images/management_index_data_stream_backing_index.png new file mode 100644 index 00000000000000..a5c577affbbb2d Binary files /dev/null and b/docs/management/images/management_index_data_stream_backing_index.png differ diff --git a/docs/management/images/management_index_data_stream_stats.png b/docs/management/images/management_index_data_stream_stats.png new file mode 100644 index 00000000000000..a67ab4a7deb320 Binary files /dev/null and b/docs/management/images/management_index_data_stream_stats.png differ diff --git a/docs/management/images/management_index_details.png b/docs/management/images/management_index_details.png index 77aeaba4723077..b199d13218f5ae 100644 Binary files a/docs/management/images/management_index_details.png and b/docs/management/images/management_index_details.png differ diff --git a/docs/management/images/management_index_labels.png b/docs/management/images/management_index_labels.png old mode 100755 new mode 100644 index 79e378e367e781..a89c32e08beff1 Binary files a/docs/management/images/management_index_labels.png and b/docs/management/images/management_index_labels.png differ diff --git a/docs/management/managing-indices.asciidoc b/docs/management/managing-indices.asciidoc index 24cd094c877c64..b199e076443ab2 100644 --- a/docs/management/managing-indices.asciidoc +++ b/docs/management/managing-indices.asciidoc @@ -2,32 +2,48 @@ [[managing-indices]] == Index Management -*Index Management* enables you to view index settings, -mappings, and statistics and perform index-level operations. -These include refreshing, flushing, clearing the cache, force merging segments, -freezing indices, and more. Practicing good index management helps ensure -that your data is stored in the most cost-effective way possible. +*Index Management* features are an easy, convenient way to manage your +{es} cluster's indices, data streams, and index templates. Practicing good index +management ensures your data is stored correctly and in the most cost-effective +way possible. -*Index Management* also helps you create index templates. A template reduces -the amount of bookkeeping when working with indices. Instead of manually -setting up your indices, you can create them automatically from a template, -ensuring that your settings, mappings, and aliases are consistently defined. +[float] +=== What you'll learn + +This page shows you how to use *Index Management* features to: -To manage your indices, open the menu, then go to *Stack Management > Data > Index Management*. +* View and edit index settings. +* View mappings and statistics for an index. +* Perform index-level operations, such as refreshes and freezes. +* View and manage data streams. +* Create index templates to automatically configure new data streams and +indices. + +To manage your indices, open the menu, then click *Stack Management > Index +Management*. [role="screenshot"] image::images/management_index_labels.png[Index Management UI] -If security is enabled, -you must have the `monitor` cluster privilege and the `view_index_metadata` +[float] +=== Before you start + +Before using this feature, you should be familiar with index management +operations. Refer to the {ref}/indices.html[index management APIs], the +{ref}/indices-templates.html[index template APIs], and the +{ref}/data-streams.html[data streams documentation]. + +[float] +=== Required permissions + +The minimum required permissions to access *Index Management* are +the `monitor` cluster privilege and the `view_index_metadata` and `manage` index privileges to view the data. For index templates, you must have the `manage_index_templates` cluster privilege. See {ref}/security-privileges.html[Security privileges] for more information. -Before using this feature, you should be familiar with index management -operations. Refer to the {ref}/indices.html[index management APIs] -and the {ref}/indices-templates.html[index template APIs]. +You can add these privileges in *Stack Management > Security > Roles*. [float] === View and edit indices @@ -50,7 +66,7 @@ image::images/management_index_details.png[Index Management UI] [float] === Perform index-level operations -Use the *Manage* menu to perform index-level operations. This menu +Use the *Manage* menu to perform index-level operations. This menu is available in the index details view, or when you select the checkbox of one or more indices on the overview page. The menu includes the following actions: @@ -78,16 +94,37 @@ searchable, but queries take longer. * *Delete index*. Permanently removes the index and all of its documents. -* *Add lifecycle policy*. Specifies a policy for managing the lifecycle of the +* *Add lifecycle policy*. Specifies a policy for managing the lifecycle of the index. +[float] +[[manage-data-streams]] +=== Manage data streams + +A {ref}/data-streams.html[data stream] lets you store time series data across +multiple backing indices while giving you a single named resource to use in +requests. The *Data Streams* view lists your data streams and lets you examine +or delete them. + +To view more information about a data stream, such as its generation or its +current index lifecycle policy, click the stream's name. + +[role="screenshot"] +image::images/management_index_data_stream_stats.png[Data stream details] + +To view information about the stream's backing indices, click the number in the +*Indices* column. + +[role="screenshot"] +image::images/management_index_data_stream_backing_index.png[Backing index] + [float] [[manage-index-templates]] === Manage index templates An index template defines {ref}/index-modules.html#index-modules-settings[settings], {ref}/mapping.html[mappings], and {ref}/indices-add-alias.html[aliases] -that you can automatically apply when creating a new index. {es} applies a +that you can automatically apply when creating a new index. {es} applies a template to a new index based on an index pattern that matches the index name. The *Index Templates* view lists your templates and enables you to examine, edit, clone, and @@ -103,33 +140,56 @@ so you must create the template before you create the indices. [float] -==== Example: Create an index template +==== Try it: Create an index template -In this example, you’ll create an index template for randomly generated log files. +In this tutorial, you’ll create an index template for randomly generated log +files. You'll then use the template to configure two new indices. -Open the *Create template* wizard, and enter `logs_template` in the *Name* -field. Set *Index pattern* to `logstash*` so the template matches any index -with that index pattern. The merge order and version are both optional, -and you'll leave them blank in this example. +*Step 1. Add a name and index pattern* +. In the *Index Templates* view, open the *Create template* wizard. ++ [role="screenshot"] image::images/management_index_create_wizard.png[Create wizard] -The second step in the *Create template* wizard allows you to define index settings. -These settings are optional, and this example skips this step. +. In the *Name* field, enter `my-index-template`. -The logs data set requires a -mapping to label the latitude and longitude pairs as geographic locations -by applying the geo_point type. In the third step of the wizard, define this mapping -under the *Mapped fields* tab as follows: +. Set *Index pattern* to `my-index-*` so the template matches any index +with that index pattern. +. Leave *Data Stream*, *Priority*, *Version*, and *_meta field* as-is or blank. + +. Click *Next*. + +*Step 2. Add settings, mappings, and index aliases* + +. Add component templates to your index template. ++ +{ref}/indices-component-template.html[Component templates] are pre-configured +sets of mappings, index settings, and index aliases you can reuse across +multiple index templates. Badges indicate whether a component template contains +mappings (*M*), index settings (*S*), index aliases (*A*), or a combination of +the three. ++ +Component templates are optional. For this tutorial, do not add any component +templates. ++ [role="screenshot"] -image::images/management-index-templates-mappings.png[Mapped fields page] +image::images/management_index_component_template.png[Component templates page] -Alternatively, you can click the *Load JSON* link and define the mapping as JSON: +. Define index settings. These are optional. For this tutorial, leave this +section blank. +. Define a mapping that contains an object field named `geo` with a child +geo-point field named `coordinates`: ++ +[role="screenshot"] +image::images/management-index-templates-mappings.png[Mapped fields page] ++ +Alternatively, you can click the *Load JSON* link and define the mapping as JSON: ++ [source,js] ----------------------------------- +---- { "properties": { "geo": { @@ -141,28 +201,33 @@ Alternatively, you can click the *Load JSON* link and define the mapping as JSON } } } ----------------------------------- - +---- ++ You can create additional mapping configurations in the *Dynamic templates* and -*Advanced options* tabs. No additional mappings are required for this example. - -In the fourth step, define an alias named `logstash`. +*Advanced options* tabs. No additional mappings are required for this tutorial. +. Define an index alias named `my-index`: ++ [source,js] ----------------------------------- +---- { - "logstash": {} + "my-index": {} } ----------------------------------- +---- + +. On the review page, check the summary. If everything looks right, click +*Create template*. -A summary of the template is in step 5. If everything looks right, click *Create template*. +*Step 3. Create new indices* -At this point, you’re ready to use the {es} index API to load the logs data. -In the {kib} *Console*, index two documents: +You’re now ready to load the logs data and create new indices using your index +template. +. In the {kib} *Console*, index the following documents: ++ [source,js] ----------------------------------- -POST /logstash-2019.05.18/_doc +---- +POST /my-index-000001/_doc { "@timestamp": "2019-05-18T15:57:27.541Z", "ip": "225.44.217.191", @@ -177,7 +242,7 @@ POST /logstash-2019.05.18/_doc "url": "https://media-for-the-masses.theacademyofperformingartsandscience.org/uploads/charles-fullerton.jpg" } -POST /logstash-2019.05.20/_doc +POST /my-index-000002/_doc { "@timestamp": "2019-05-20T03:44:20.844Z", "ip": "198.247.165.49", @@ -192,7 +257,10 @@ POST /logstash-2019.05.20/_doc "memory": 241720, "url": "https://theacademyofperformingartsandscience.org/people/type:astronauts/name:laurel-b-clark/profile" } ----------------------------------- +---- ++ +These requests create two indices: `my-index-000001` and `my-index-000002`. -The mappings and alias are configured automatically based on the template. To verify, you -can view one of the newly created indices using the {ref}/indices-get-index.html#indices-get-index[index API]. +. Use the {es} {ref}/indices-get-index.html#indices-get-index[get index API] to +view one of the newly created indices. The index's mappings and alias are +configured automatically based on the template. diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index d8c200450d7e52..7fc8fe5114e1e4 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -28,12 +28,12 @@ This page has moved. Please see the new section in the {heartbeat-ref}/securing- [role="exclude",id="infra-read-only-access"] == Configure source read-only access -This page has moved. Please see the new section in the {metrics-guide}/configure-metrics-source.html[Metrics Monitoring Guide]. +This page has moved. Please see {observability-guide}/configure-settings.html[configure settings]. [role="exclude",id="logs-read-only-access"] == Configure source read-only access -This page has moved. Please see {logs-guide}/configure-logs-source.html[logs configuration]. +This page has moved. Please see {observability-guide}/configure-data-sources.html[configure data sources]. [role="exclude",id="extend"] == Extend your use case diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 917821ad09e2fc..f48dbeab9d61a8 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -76,6 +76,9 @@ a|`monitoring.cluster_alerts.` health checks. By default, it matches the <> setting, which has a default value of `30000`. +| `monitoring.ui.elasticsearch.ssl` + | Shares the same configuration as <>. These settings configure encrypted communication between {kib} and the monitoring cluster. + |=== [float] diff --git a/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts b/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts index 340f45a0a2c189..b7ffefe7005e1d 100644 --- a/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts +++ b/src/core/server/core_app/integration_tests/default_route_provider_config.test.ts @@ -24,7 +24,8 @@ const { startES } = kbnTestServer.createTestServers({ }); let esServer: kbnTestServer.TestElasticsearchUtils; -describe('default route provider', () => { +// FLAKY: https://github.com/elastic/kibana/issues/81072 +describe.skip('default route provider', () => { let root: Root; beforeAll(async () => { diff --git a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap index ed84aceb60e5ab..dc4da2456b47be 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap +++ b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_pattern.test.ts.snap @@ -2,6 +2,7 @@ exports[`IndexPattern toSpec should match snapshot 1`] = ` Object { + "fieldFormats": Object {}, "fields": Object { "@tags": Object { "aggregatable": true, diff --git a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap index 752fdcf11991ce..a3d19f311b7654 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap +++ b/src/plugins/data/common/index_patterns/index_patterns/__snapshots__/index_patterns.test.ts.snap @@ -2,6 +2,9 @@ exports[`IndexPatterns savedObjectToSpec 1`] = ` Object { + "fieldFormats": Object { + "field": Object {}, + }, "fields": Object {}, "id": "id", "intervalName": undefined, diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index 6e11bc8f1d508f..9fd43be8dc5b37 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -183,6 +183,20 @@ describe('IndexPattern', () => { }); }); + describe('setFieldFormat and deleteFieldFormaat', () => { + test('should persist changes', () => { + const formatter = { + toJSON: () => ({ id: 'bytes' }), + } as FieldFormat; + indexPattern.getFormatterForField = () => formatter; + indexPattern.setFieldFormat('bytes', { id: 'bytes' }); + expect(indexPattern.toSpec().fieldFormats).toEqual({ bytes: { id: 'bytes' } }); + + indexPattern.deleteFieldFormat('bytes'); + expect(indexPattern.toSpec().fieldFormats).toEqual({}); + }); + }); + describe('toSpec', () => { test('should match snapshot', () => { const formatter = { @@ -209,7 +223,6 @@ describe('IndexPattern', () => { expect(restoredPattern.title).toEqual(indexPattern.title); expect(restoredPattern.timeFieldName).toEqual(indexPattern.timeFieldName); expect(restoredPattern.fields.length).toEqual(indexPattern.fields.length); - expect(restoredPattern.fieldFormatMap.bytes instanceof MockFieldFormatter).toEqual(true); }); }); }); diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index 5fc6344c935d5b..d38df68e9f4286 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -21,13 +21,7 @@ import _, { each, reject } from 'lodash'; import { SavedObjectsClientCommon } from '../..'; import { DuplicateField } from '../../../../kibana_utils/common'; -import { - ES_FIELD_TYPES, - KBN_FIELD_TYPES, - IIndexPattern, - FieldFormatNotFoundError, - IFieldType, -} from '../../../common'; +import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern, IFieldType } from '../../../common'; import { IndexPatternField, IIndexPatternFieldList, fieldList } from '../fields'; import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; @@ -102,7 +96,7 @@ export class IndexPattern implements IIndexPattern { // set values this.id = spec.id; - const fieldFormatMap = this.fieldSpecsToFieldFormatMap(spec.fields); + this.fieldFormatMap = spec.fieldFormats || {}; this.version = spec.version; @@ -113,12 +107,16 @@ export class IndexPattern implements IIndexPattern { this.fields.replaceAll(Object.values(spec.fields || {})); this.type = spec.type; this.typeMeta = spec.typeMeta; - - this.fieldFormatMap = _.mapValues(fieldFormatMap, (mapping) => { - return this.deserializeFieldFormatMap(mapping); - }); } + setFieldFormat = (fieldName: string, format: SerializedFieldFormat) => { + this.fieldFormatMap[fieldName] = format; + }; + + deleteFieldFormat = (fieldName: string) => { + delete this.fieldFormatMap[fieldName]; + }; + /** * Get last saved saved object fields */ @@ -131,34 +129,6 @@ export class IndexPattern implements IIndexPattern { this.originalSavedObjectBody = this.getAsSavedObjectBody(); }; - /** - * Converts field format spec to field format instance - * @param mapping - */ - private deserializeFieldFormatMap(mapping: SerializedFieldFormat>) { - try { - return this.fieldFormats.getInstance(mapping.id as string, mapping.params); - } catch (err) { - if (err instanceof FieldFormatNotFoundError) { - return undefined; - } else { - throw err; - } - } - } - - /** - * Extracts FieldFormatMap from FieldSpec map - * @param fldList FieldSpec map - */ - private fieldSpecsToFieldFormatMap = (fldList: IndexPatternSpec['fields'] = {}) => - Object.values(fldList).reduce>((col, fieldSpec) => { - if (fieldSpec.format) { - col[fieldSpec.name] = { ...fieldSpec.format }; - } - return col; - }, {}); - getComputedFields() { const scriptFields: any = {}; if (!this.fields) { @@ -211,6 +181,7 @@ export class IndexPattern implements IIndexPattern { fields: this.fields.toSpec({ getFormatterForField: this.getFormatterForField.bind(this) }), typeMeta: this.typeMeta, type: this.type, + fieldFormats: this.fieldFormatMap, }; } @@ -299,17 +270,9 @@ export class IndexPattern implements IIndexPattern { * Returns index pattern as saved object body for saving */ getAsSavedObjectBody() { - const serializeFieldFormatMap = ( - flat: any, - format: FieldFormat | undefined, - field: string | undefined - ) => { - if (format && field) { - flat[field] = format; - } - }; - const serialized = _.transform(this.fieldFormatMap, serializeFieldFormatMap); - const fieldFormatMap = _.isEmpty(serialized) ? undefined : JSON.stringify(serialized); + const fieldFormatMap = _.isEmpty(this.fieldFormatMap) + ? undefined + : JSON.stringify(this.fieldFormatMap); return { title: this.title, @@ -330,12 +293,25 @@ export class IndexPattern implements IIndexPattern { getFormatterForField( field: IndexPatternField | IndexPatternField['spec'] | IFieldType ): FieldFormat { - return ( - this.fieldFormatMap[field.name] || - this.fieldFormats.getDefaultInstance( + const formatSpec = this.fieldFormatMap[field.name]; + if (formatSpec) { + return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); + } else { + return this.fieldFormats.getDefaultInstance( field.type as KBN_FIELD_TYPES, field.esTypes as ES_FIELD_TYPES[] - ) - ); + ); + } + } + + /** + * Get formatter for a given field name. Return undefined if none exists + * @param field + */ + getFormatterForFieldNoDefault(fieldname: string) { + const formatSpec = this.fieldFormatMap[fieldname]; + if (formatSpec) { + return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); + } } } diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 9a86541376cd80..bfd0dc9d946c24 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -35,7 +35,6 @@ import { IndexPatternSpec, IndexPatternAttributes, FieldSpec, - FieldFormatMap, IndexPatternFieldMap, } from '../types'; import { FieldFormatsStartCommon } from '../../field_formats'; @@ -296,20 +295,6 @@ export class IndexPatternsService { return fields; }; - /** - * Applies a set of formats to a set of fields - * @param fieldSpecs - * @param fieldFormatMap - */ - private addFormatsToFields = (fieldSpecs: FieldSpec[], fieldFormatMap: FieldFormatMap) => { - Object.entries(fieldFormatMap).forEach(([fieldName, value]) => { - const field = fieldSpecs.find((fld: FieldSpec) => fld.name === fieldName); - if (field) { - field.format = value; - } - }); - }; - /** * Converts field array to map * @param fields @@ -346,7 +331,6 @@ export class IndexPatternsService { const parsedFieldFormatMap = fieldFormatMap ? JSON.parse(fieldFormatMap) : {}; const parsedFields: FieldSpec[] = fields ? JSON.parse(fields) : []; - this.addFormatsToFields(parsedFields, parsedFieldFormatMap); return { id, version, @@ -357,6 +341,7 @@ export class IndexPatternsService { fields: this.fieldArrayToMap(parsedFields), typeMeta: parsedTypeMeta, type, + fieldFormats: parsedFieldFormatMap, }; }; @@ -382,9 +367,6 @@ export class IndexPatternsService { const spec = this.savedObjectToSpec(savedObject); const { title, type, typeMeta } = spec; - const parsedFieldFormats: FieldFormatMap = savedObject.attributes.fieldFormatMap - ? JSON.parse(savedObject.attributes.fieldFormatMap) - : {}; const isFieldRefreshRequired = this.isFieldRefreshRequired(spec.fields); let isSaveRequired = isFieldRefreshRequired; @@ -415,12 +397,9 @@ export class IndexPatternsService { } } - Object.entries(parsedFieldFormats).forEach(([fieldName, value]) => { - const field = spec.fields?.[fieldName]; - if (field) { - field.format = value; - } - }); + spec.fieldFormats = savedObject.attributes.fieldFormatMap + ? JSON.parse(savedObject.attributes.fieldFormatMap) + : {}; const indexPattern = await this.create(spec, true); indexPatternCache.set(id, indexPattern); diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index cb0c3aa0de38ef..3387bc3b3c19e4 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -172,6 +172,7 @@ export interface IndexPatternSpec { fields?: IndexPatternFieldMap; typeMeta?: TypeMeta; type?: string; + fieldFormats?: Record; } export interface SourceFilter { diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts index 6a2d6edd046923..dd1a9a7f689a93 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types.test.ts @@ -66,6 +66,7 @@ describe('utils/kbn_field_types', () => { test('returns the kbnFieldType name that matches the esType', () => { expect(castEsToKbnFieldTypeName(ES_FIELD_TYPES.KEYWORD)).toBe('string'); expect(castEsToKbnFieldTypeName(ES_FIELD_TYPES.FLOAT)).toBe('number'); + expect(castEsToKbnFieldTypeName(ES_FIELD_TYPES.UNSIGNED_LONG)).toBe('number'); }); test('returns unknown for unknown es types', () => { diff --git a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts index b93ebcbbca9c8a..373cdfda306076 100644 --- a/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts +++ b/src/plugins/data/common/kbn_field_types/kbn_field_types_factory.ts @@ -48,6 +48,7 @@ export const createKbnFieldTypes = (): KbnFieldType[] => [ ES_FIELD_TYPES.DOUBLE, ES_FIELD_TYPES.INTEGER, ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.UNSIGNED_LONG, ES_FIELD_TYPES.SHORT, ES_FIELD_TYPES.BYTE, ES_FIELD_TYPES.TOKEN_COUNT, diff --git a/src/plugins/data/common/kbn_field_types/types.ts b/src/plugins/data/common/kbn_field_types/types.ts index acd7a36b01fb30..ba9fd3e70b3152 100644 --- a/src/plugins/data/common/kbn_field_types/types.ts +++ b/src/plugins/data/common/kbn_field_types/types.ts @@ -52,6 +52,7 @@ export enum ES_FIELD_TYPES { INTEGER = 'integer', LONG = 'long', SHORT = 'short', + UNSIGNED_LONG = 'unsigned_long', NESTED = 'nested', BYTE = 'byte', diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index d280b6f1faf7d4..1390b28ec830db 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -514,7 +514,9 @@ export enum ES_FIELD_TYPES { // (undocumented) TOKEN_COUNT = "token_count", // (undocumented) - _TYPE = "_type" + _TYPE = "_type", + // (undocumented) + UNSIGNED_LONG = "unsigned_long" } // Warning: (ae-missing-release-tag) "ES_SEARCH_STRATEGY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1063,6 +1065,8 @@ export class IndexPattern implements IIndexPattern { constructor({ spec, fieldFormats, shortDotsEnable, metaFields, }: IndexPatternDeps); addScriptedField(name: string, script: string, fieldType?: string): Promise; // (undocumented) + deleteFieldFormat: (fieldName: string) => void; + // (undocumented) fieldFormatMap: Record; // (undocumented) fields: IIndexPatternFieldList & { @@ -1108,6 +1112,7 @@ export class IndexPattern implements IIndexPattern { // (undocumented) getFieldByName(name: string): IndexPatternField | undefined; getFormatterForField(field: IndexPatternField | IndexPatternField['spec'] | IFieldType): FieldFormat; + getFormatterForFieldNoDefault(fieldname: string): FieldFormat | undefined; // (undocumented) getNonScriptedFields(): IndexPatternField[]; getOriginalSavedObjectBody: () => { @@ -1139,6 +1144,8 @@ export class IndexPattern implements IIndexPattern { metaFields: string[]; removeScriptedField(fieldName: string): void; resetOriginalSavedObjectBody: () => void; + // (undocumented) + setFieldFormat: (fieldName: string, format: SerializedFieldFormat) => void; // Warning: (ae-forgotten-export) The symbol "SourceFilter" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1287,6 +1294,8 @@ export type IndexPatternSelectProps = Required, 'isLo // // @public (undocumented) export interface IndexPatternSpec { + // (undocumented) + fieldFormats?: Record; // (undocumented) fields?: IndexPatternFieldMap; // (undocumented) @@ -2274,7 +2283,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:70:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:64:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:98:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:67:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 0ed296a1d0662d..65313adfc0e0f2 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -211,7 +211,9 @@ export enum ES_FIELD_TYPES { // (undocumented) TOKEN_COUNT = "token_count", // (undocumented) - _TYPE = "_type" + _TYPE = "_type", + // (undocumented) + UNSIGNED_LONG = "unsigned_long" } // Warning: (ae-missing-release-tag) "ES_SEARCH_STRATEGY" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -513,6 +515,8 @@ export class IndexPattern implements IIndexPattern { constructor({ spec, fieldFormats, shortDotsEnable, metaFields, }: IndexPatternDeps); addScriptedField(name: string, script: string, fieldType?: string): Promise; // (undocumented) + deleteFieldFormat: (fieldName: string) => void; + // (undocumented) fieldFormatMap: Record; // Warning: (ae-forgotten-export) The symbol "IIndexPatternFieldList" needs to be exported by the entry point index.d.ts // @@ -560,6 +564,7 @@ export class IndexPattern implements IIndexPattern { // (undocumented) getFieldByName(name: string): IndexPatternField | undefined; getFormatterForField(field: IndexPatternField | IndexPatternField['spec'] | IFieldType): FieldFormat; + getFormatterForFieldNoDefault(fieldname: string): FieldFormat | undefined; // Warning: (ae-forgotten-export) The symbol "IndexPatternField" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -593,6 +598,10 @@ export class IndexPattern implements IIndexPattern { metaFields: string[]; removeScriptedField(fieldName: string): void; resetOriginalSavedObjectBody: () => void; + // Warning: (ae-forgotten-export) The symbol "SerializedFieldFormat" needs to be exported by the entry point index.d.ts + // + // (undocumented) + setFieldFormat: (fieldName: string, format: SerializedFieldFormat) => void; // Warning: (ae-forgotten-export) The symbol "SourceFilter" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1104,8 +1113,8 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // // src/plugins/data/common/es_query/filters/meta_filter.ts:53:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:64:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:70:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:58:45 - (ae-forgotten-export) The symbol "IndexPatternFieldMap" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:64:5 - (ae-forgotten-export) The symbol "FormatFieldFn" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/angular/context/query/actions.js b/src/plugins/discover/public/application/angular/context/query/actions.js index 32fc2873d7f2a2..d5c72d34006e2e 100644 --- a/src/plugins/discover/public/application/angular/context/query/actions.js +++ b/src/plugins/discover/public/application/angular/context/query/actions.js @@ -25,7 +25,7 @@ import { getServices } from '../../../../kibana_services'; import { fetchAnchorProvider } from '../api/anchor'; import { fetchContextProvider } from '../api/context'; import { getQueryParameterActions } from '../query_parameters'; -import { FAILURE_REASONS, LOADING_STATUS } from './constants'; +import { FAILURE_REASONS, LOADING_STATUS } from './index'; import { MarkdownSimple } from '../../../../../../kibana_react/public'; export function QueryActionsProvider(Promise) { diff --git a/src/plugins/discover/public/application/angular/context/query/index.js b/src/plugins/discover/public/application/angular/context/query/index.js index f9b1a35e32feac..59d1f165d19d4d 100644 --- a/src/plugins/discover/public/application/angular/context/query/index.js +++ b/src/plugins/discover/public/application/angular/context/query/index.js @@ -18,5 +18,5 @@ */ export { QueryActionsProvider } from './actions'; -export { FAILURE_REASONS, LOADING_STATUS } from './constants'; +export { FAILURE_REASONS, LOADING_STATUS } from '../../../components/context_app/constants'; export { createInitialLoadingStatusState } from './state'; diff --git a/src/plugins/discover/public/application/angular/context/query/state.js b/src/plugins/discover/public/application/angular/context/query/state.js index 06fd0680d347f7..142b5746249bf1 100644 --- a/src/plugins/discover/public/application/angular/context/query/state.js +++ b/src/plugins/discover/public/application/angular/context/query/state.js @@ -17,7 +17,7 @@ * under the License. */ -import { LOADING_STATUS } from './constants'; +import { LOADING_STATUS } from './index'; export function createInitialLoadingStatusState() { return { diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover/public/application/angular/context_app.html index 6adcaeeae94f55..d609a497c4ba1e 100644 --- a/src/plugins/discover/public/application/angular/context_app.html +++ b/src/plugins/discover/public/application/angular/context_app.html @@ -12,8 +12,8 @@ - @@ -35,39 +35,17 @@ type="'predecessors'" > - - -
-
-
- -
-
- -
-
+ , -
-
-
-
-
-
-
-
-

- Refine your query -

-

- The search bar at the top uses Elasticsearch’s support for Lucene - - Query String syntax - - . Here are some examples of how you can search for web server logs that have been parsed into a few fields. -

-
-
-
-
-
- - Find requests that contain the number 200, in any field - -
-
-
- - - 200 - - -
-
-
- - Find 200 in the status field - -
-
-
- - - status:200 - - -
-
-
- - Find all status codes between 400-499 - -
-
-
- - - status:[400 TO 499] - - -
-
-
- - Find status codes 400-499 with the extension php - -
-
-
- - - status:[400 TO 499] AND extension:PHP - - -
-
-
- - Find status codes 400-499 with the extension php or html - -
-
-
- - - status:[400 TO 499] AND (extension:php OR extension:html) - - -
-
-
-
-
, -] -`; - -exports[`DiscoverNoResults props timeFieldName renders time range feedback 1`] = ` -Array [ -
, -
-
-
-
-
-
-
-
-

- Expand your time range -

-

- One or more of the indices you’re looking at contains a date field. Your query may not match anything in the current time range, or there may not be any data at all in the currently selected time range. You can try changing the time range to one which contains data. -

-
-
-
, -] -`; diff --git a/src/plugins/discover/public/application/angular/directives/_index.scss b/src/plugins/discover/public/application/angular/directives/_index.scss index d4b365547b40c5..dfacdf45c9d7bf 100644 --- a/src/plugins/discover/public/application/angular/directives/_index.scss +++ b/src/plugins/discover/public/application/angular/directives/_index.scss @@ -1,2 +1 @@ -@import 'no_results'; @import 'histogram'; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts b/src/plugins/discover/public/application/angular/directives/index.ts similarity index 72% rename from src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts rename to src/plugins/discover/public/application/angular/directives/index.ts index 861017d99962ec..2e120995cce073 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.ts +++ b/src/plugins/discover/public/application/angular/directives/index.ts @@ -17,11 +17,5 @@ * under the License. */ -import { get } from 'lodash'; -import { IIndexPattern } from '../../../../../../data/public'; - -export function getFieldFormat(indexPattern?: IIndexPattern, fieldName?: string): string { - return indexPattern && fieldName - ? get(indexPattern, ['fieldFormatMap', fieldName, 'type', 'title']) - : ''; -} +export { DiscoverUninitialized } from './uninitialized'; +export { DiscoverHistogram } from './histogram'; diff --git a/src/plugins/discover/public/application/angular/directives/no_results.js b/src/plugins/discover/public/application/angular/directives/no_results.js deleted file mode 100644 index d8a39d9178e937..00000000000000 --- a/src/plugins/discover/public/application/angular/directives/no_results.js +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Component, Fragment } from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import PropTypes from 'prop-types'; - -import { - EuiCallOut, - EuiCode, - EuiDescriptionList, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { getServices } from '../../../kibana_services'; - -// eslint-disable-next-line react/prefer-stateless-function -export class DiscoverNoResults extends Component { - static propTypes = { - timeFieldName: PropTypes.string, - queryLanguage: PropTypes.string, - }; - - render() { - const { timeFieldName, queryLanguage } = this.props; - - let timeFieldMessage; - - if (timeFieldName) { - timeFieldMessage = ( - - - - -

- -

- -

- -

-
-
- ); - } - - let luceneQueryMessage; - - if (queryLanguage === 'lucene') { - const searchExamples = [ - { - description: 200, - title: ( - - - - - - ), - }, - { - description: status:200, - title: ( - - - - - - ), - }, - { - description: status:[400 TO 499], - title: ( - - - - - - ), - }, - { - description: status:[400 TO 499] AND extension:PHP, - title: ( - - - - - - ), - }, - { - description: status:[400 TO 499] AND (extension:php OR extension:html), - title: ( - - - - - - ), - }, - ]; - - luceneQueryMessage = ( - - - - -

- -

- -

- - - - ), - }} - /> -

-
- - - - - - -
- ); - } - - return ( - - - - - - - - } - color="warning" - iconType="help" - data-test-subj="discoverNoResults" - /> - {timeFieldMessage} - {luceneQueryMessage} - - - - - ); - } -} diff --git a/src/plugins/discover/public/application/angular/directives/no_results.test.js b/src/plugins/discover/public/application/angular/directives/no_results.test.js deleted file mode 100644 index 60c50048a39ef5..00000000000000 --- a/src/plugins/discover/public/application/angular/directives/no_results.test.js +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { renderWithIntl } from 'test_utils/enzyme_helpers'; - -import { DiscoverNoResults } from './no_results'; - -jest.mock('../../../kibana_services', () => { - return { - getServices: () => ({ - docLinks: { - links: { - query: { - luceneQuerySyntax: 'documentation-link', - }, - }, - }, - }), - }; -}); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('DiscoverNoResults', () => { - describe('props', () => { - describe('timeFieldName', () => { - test('renders time range feedback', () => { - const component = renderWithIntl(); - - expect(component).toMatchSnapshot(); - }); - }); - - describe('queryLanguage', () => { - test('supports lucene and renders doc link', () => { - const component = renderWithIntl( - 'documentation-link'} /> - ); - - expect(component).toMatchSnapshot(); - }); - }); - }); -}); diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 612cedb7780bd3..ebd086dd1e38a6 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -87,6 +87,7 @@ const fetchStatuses = { UNINITIALIZED: 'uninitialized', LOADING: 'loading', COMPLETE: 'complete', + ERROR: 'error', }; const app = getAngularModule(); @@ -620,6 +621,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise config: config, fixedScroll: createFixedScroll($scope, $timeout), setHeaderActionMenu: getHeaderActionMenuMounter(), + data, }; const shouldSearchOnPageLoad = () => { @@ -685,7 +687,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise function pick(rows, oldRows, fetchStatus) { // initial state, pretend we're already loading if we're about to execute a search so // that the uninitilized message doesn't flash on screen - if (rows == null && oldRows == null && shouldSearchOnPageLoad()) { + if (!$scope.fetchError && rows == null && oldRows == null && shouldSearchOnPageLoad()) { return status.LOADING; } @@ -814,7 +816,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise if (error instanceof Error && error.name === 'AbortError') return; $scope.fetchStatus = fetchStatuses.NO_RESULTS; - $scope.rows = []; + $scope.fetchError = error; data.search.showError(error); }); diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx index ad2b674af014c8..f191fa2dc89e84 100644 --- a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -68,6 +68,7 @@ export function convertDirectiveToRenderFn( let rejected = false; const cleanupFnPromise = injectAngularElement(domNode, directive.template, props, getInjector); + cleanupFnPromise.catch(() => { rejected = true; render(
error
, domNode); @@ -91,10 +92,10 @@ export interface DocTableLegacyProps { rows: Array>; indexPattern: IIndexPattern; minimumVisibleRows: number; - onAddColumn: (column: string) => void; - onSort: (sort: string[][]) => void; - onMoveColumn: (columns: string, newIdx: number) => void; - onRemoveColumn: (column: string) => void; + onAddColumn?: (column: string) => void; + onSort?: (sort: string[][]) => void; + onMoveColumn?: (columns: string, newIdx: number) => void; + onRemoveColumn?: (column: string) => void; sort?: string[][]; } diff --git a/src/plugins/discover/public/application/components/context_app/__snapshots__/context_app_legacy.test.tsx.snap b/src/plugins/discover/public/application/components/context_app/__snapshots__/context_app_legacy.test.tsx.snap new file mode 100644 index 00000000000000..58305ee23cb210 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_app/__snapshots__/context_app_legacy.test.tsx.snap @@ -0,0 +1,741 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextAppLegacy test renders correctly 1`] = ` + + + + + +
+
+ +
+ +
+
+ + + + + +`; + +exports[`ContextAppLegacy test renders loading indicator 1`] = ` + + + + + +
+ +
+ +
+ + Loading... + +
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/discover/public/application/angular/context/query/constants.js b/src/plugins/discover/public/application/components/context_app/constants.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/constants.js rename to src/plugins/discover/public/application/components/context_app/constants.ts diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx b/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx new file mode 100644 index 00000000000000..16d8cd78004f92 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy.test.tsx @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { ContextAppLegacy } from './context_app_legacy'; +import { IIndexPattern } from '../../../../../data/common/index_patterns'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DocTableLegacy } from '../../angular/doc_table/create_doc_table_react'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +describe('ContextAppLegacy test', () => { + const hit = { + _id: '123', + _index: 'test_index', + _score: null, + _version: 1, + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Walker', + customer_full_name: 'Walker Texas Ranger', + customer_gender: 'MALE', + customer_last_name: 'Ranger', + }, + fields: [{ order_date: ['2020-10-19T13:35:02.000Z'] }], + sort: [1603114502000, 2092], + }; + const indexPattern = { + id: 'test_index_pattern', + } as IIndexPattern; + const defaultProps = { + columns: ['_source'], + filter: () => {}, + hits: [hit], + infiniteScroll: true, + sorting: ['order_date', 'desc'], + minimumVisibleRows: 5, + indexPattern, + status: 'loaded', + }; + + it('renders correctly', () => { + const component = mountWithIntl(); + expect(component).toMatchSnapshot(); + expect(component.find(DocTableLegacy).length).toBe(1); + const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator'); + expect(loadingIndicator.length).toBe(0); + }); + + it('renders loading indicator', () => { + const props = { ...defaultProps }; + props.status = 'loading'; + const component = mountWithIntl(); + expect(component).toMatchSnapshot(); + expect(component.find('DocTableLegacy').length).toBe(0); + const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator'); + expect(loadingIndicator.length).toBe(1); + }); +}); diff --git a/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx new file mode 100644 index 00000000000000..ee8b2f590f71c0 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy.tsx @@ -0,0 +1,76 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPanel, EuiText } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { + DocTableLegacy, + DocTableLegacyProps, +} from '../../angular/doc_table/create_doc_table_react'; +import { IIndexPattern, IndexPatternField } from '../../../../../data/common/index_patterns'; +import { LOADING_STATUS } from './constants'; + +export interface ContextAppProps { + columns: string[]; + hits: Array>; + indexPattern: IIndexPattern; + filter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + minimumVisibleRows: number; + sorting: string[]; + status: string; +} + +export function ContextAppLegacy(renderProps: ContextAppProps) { + const { hits, filter, sorting, status } = renderProps; + const props = ({ ...renderProps } as unknown) as DocTableLegacyProps; + props.rows = hits; + props.onFilter = filter; + props.sort = sorting.map((el) => [el]); + const isLoaded = status === LOADING_STATUS.LOADED; + const loadingFeedback = () => { + if (status === LOADING_STATUS.UNINITIALIZED || status === LOADING_STATUS.LOADING) { + return ( + + + + + + ); + } + return null; + }; + return ( + + + {loadingFeedback()} + {isLoaded ? ( + +
+ +
+
+ ) : null} +
+
+ ); +} diff --git a/src/plugins/discover/public/application/angular/directives/index.js b/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts similarity index 58% rename from src/plugins/discover/public/application/angular/directives/index.js rename to src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts index 5d8969a78f0182..af94c5537da288 100644 --- a/src/plugins/discover/public/application/angular/directives/index.js +++ b/src/plugins/discover/public/application/components/context_app/context_app_legacy_directive.ts @@ -16,16 +16,17 @@ * specific language governing permissions and limitations * under the License. */ +import { ContextAppLegacy } from './context_app_legacy'; -import { DiscoverNoResults } from './no_results'; -import { DiscoverUninitialized } from './uninitialized'; -import { DiscoverHistogram } from './histogram'; -import { getAngularModule } from '../../../kibana_services'; - -const app = getAngularModule(); - -app.directive('discoverNoResults', (reactDirective) => reactDirective(DiscoverNoResults)); - -app.directive('discoverUninitialized', (reactDirective) => reactDirective(DiscoverUninitialized)); - -app.directive('discoverHistogram', (reactDirective) => reactDirective(DiscoverHistogram)); +export function createContextAppLegacy(reactDirective: any) { + return reactDirective(ContextAppLegacy, [ + ['filter', { watchDepth: 'reference' }], + ['hits', { watchDepth: 'reference' }], + ['indexPattern', { watchDepth: 'reference' }], + ['sorting', { watchDepth: 'reference' }], + ['columns', { watchDepth: 'collection' }], + ['infiniteScroll', { watchDepth: 'reference' }], + ['minimumVisibleRows', { watchDepth: 'reference' }], + ['status', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover/public/application/components/discover_legacy.tsx index 139b2ca69d9e47..3ca421f809640b 100644 --- a/src/plugins/discover/public/application/components/discover_legacy.tsx +++ b/src/plugins/discover/public/application/components/discover_legacy.tsx @@ -26,10 +26,8 @@ import { HitsCounter } from './hits_counter'; import { TimechartHeader } from './timechart_header'; import { DiscoverSidebar } from './sidebar'; import { getServices, IndexPattern } from '../../kibana_services'; -// @ts-ignore -import { DiscoverNoResults } from '../angular/directives/no_results'; -import { DiscoverUninitialized } from '../angular/directives/uninitialized'; -import { DiscoverHistogram } from '../angular/directives/histogram'; +import { DiscoverUninitialized, DiscoverHistogram } from '../angular/directives'; +import { DiscoverNoResults } from './no_results'; import { LoadingSpinner } from './loading_spinner/loading_spinner'; import { DocTableLegacy } from '../angular/doc_table/create_doc_table_react'; import { SkipBottomButton } from './skip_bottom_button'; @@ -40,6 +38,7 @@ import { TimeRange, Query, IndexPatternAttributes, + DataPublicPluginStart, } from '../../../../data/public'; import { Chart } from '../angular/helpers/point_series'; import { AppState } from '../angular/discover_state'; @@ -53,6 +52,7 @@ export interface DiscoverLegacyProps { addColumn: (column: string) => void; fetch: () => void; fetchCounter: number; + fetchError: Error; fieldCounts: Record; histogramData: Chart; hits: number; @@ -73,6 +73,7 @@ export interface DiscoverLegacyProps { sampleSize: number; fixedScroll: (el: HTMLElement) => void; setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + data: DataPublicPluginStart; }; resetQuery: () => void; resultState: string; @@ -94,6 +95,7 @@ export function DiscoverLegacy({ fetch, fetchCounter, fieldCounts, + fetchError, histogramData, hits, indexPattern, @@ -208,6 +210,8 @@ export function DiscoverLegacy({ )} {resultState === 'uninitialized' && } diff --git a/src/plugins/discover/public/application/angular/directives/_no_results.scss b/src/plugins/discover/public/application/components/no_results/_no_results.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_no_results.scss rename to src/plugins/discover/public/application/components/no_results/_no_results.scss diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/index.ts b/src/plugins/discover/public/application/components/no_results/index.ts similarity index 93% rename from src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/index.ts rename to src/plugins/discover/public/application/components/no_results/index.ts index 9ab950fbfb2f20..afe35b1fd7c18f 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/index.ts +++ b/src/plugins/discover/public/application/components/no_results/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { getFieldFormat } from './get_field_format'; +export { DiscoverNoResults } from './no_results'; diff --git a/src/plugins/discover/public/application/components/no_results/no_results.test.tsx b/src/plugins/discover/public/application/components/no_results/no_results.test.tsx new file mode 100644 index 00000000000000..dde75236eb15ec --- /dev/null +++ b/src/plugins/discover/public/application/components/no_results/no_results.test.tsx @@ -0,0 +1,118 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; + +import { DiscoverNoResults, DiscoverNoResultsProps } from './no_results'; + +jest.mock('../../../kibana_services', () => { + return { + getServices: () => ({ + docLinks: { + links: { + query: { + luceneQuerySyntax: 'documentation-link', + }, + }, + }, + }), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +function mountAndFindSubjects(props: DiscoverNoResultsProps) { + const component = mountWithIntl(); + return { + mainMsg: findTestSubject(component, 'discoverNoResults').length > 0, + timeFieldMsg: findTestSubject(component, 'discoverNoResultsTimefilter').length > 0, + luceneMsg: findTestSubject(component, 'discoverNoResultsLucene').length > 0, + errorMsg: findTestSubject(component, 'discoverNoResultsError').length > 0, + }; +} + +describe('DiscoverNoResults', () => { + describe('props', () => { + describe('no props', () => { + test('renders default feedback', () => { + const result = mountAndFindSubjects({}); + expect(result).toMatchInlineSnapshot(` + Object { + "errorMsg": false, + "luceneMsg": false, + "mainMsg": true, + "timeFieldMsg": false, + } + `); + }); + }); + describe('timeFieldName', () => { + test('renders time range feedback', () => { + const result = mountAndFindSubjects({ + timeFieldName: 'awesome_time_field', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "errorMsg": false, + "luceneMsg": false, + "mainMsg": true, + "timeFieldMsg": true, + } + `); + }); + }); + + describe('queryLanguage', () => { + test('supports lucene and renders doc link', () => { + const result = mountAndFindSubjects({ queryLanguage: 'lucene' }); + expect(result).toMatchInlineSnapshot(` + Object { + "errorMsg": false, + "luceneMsg": true, + "mainMsg": true, + "timeFieldMsg": false, + } + `); + }); + }); + + describe('error message', () => { + test('renders error message', () => { + const error = new Error('Fatal error'); + const result = mountAndFindSubjects({ + timeFieldName: 'awesome_time_field', + error, + queryLanguage: 'lucene', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "errorMsg": true, + "luceneMsg": false, + "mainMsg": false, + "timeFieldMsg": false, + } + `); + }); + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/no_results/no_results.tsx b/src/plugins/discover/public/application/components/no_results/no_results.tsx new file mode 100644 index 00000000000000..fcc2912d16dd5d --- /dev/null +++ b/src/plugins/discover/public/application/components/no_results/no_results.tsx @@ -0,0 +1,92 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { getServices } from '../../../kibana_services'; +import { DataPublicPluginStart } from '../../../../../data/public'; +import { getLuceneQueryMessage, getTimeFieldMessage } from './no_results_helper'; +import './_no_results.scss'; + +export interface DiscoverNoResultsProps { + timeFieldName?: string; + queryLanguage?: string; + error?: Error; + data?: DataPublicPluginStart; +} + +export function DiscoverNoResults({ + timeFieldName, + queryLanguage, + error, + data, +}: DiscoverNoResultsProps) { + const callOut = !error ? ( + + + } + color="warning" + iconType="help" + data-test-subj="discoverNoResults" + /> + {timeFieldName ? getTimeFieldMessage() : null} + {queryLanguage === 'lucene' + ? getLuceneQueryMessage(getServices().docLinks.links.query.luceneQuerySyntax) + : null} + + ) : ( + + + } + color="danger" + iconType="alert" + data-test-subj="discoverNoResultsError" + > + (data ? data.search.showError(error) : void 0)} + > + + + + + ); + + return ( + + + {callOut} + + ); +} diff --git a/src/plugins/discover/public/application/components/no_results/no_results_helper.tsx b/src/plugins/discover/public/application/components/no_results/no_results_helper.tsx new file mode 100644 index 00000000000000..fbc655e01bdf52 --- /dev/null +++ b/src/plugins/discover/public/application/components/no_results/no_results_helper.tsx @@ -0,0 +1,149 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode, EuiDescriptionList, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; + +export function getTimeFieldMessage() { + return ( + + + +

+ +

+

+ +

+
+
+ ); +} + +export function getLuceneQueryMessage(link: string) { + const searchExamples = [ + { + description: 200, + title: ( + + + + + + ), + }, + { + description: status:200, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499], + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND extension:PHP, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND (extension:php OR extension:html), + title: ( + + + + + + ), + }, + ]; + return ( + + + +

+ +

+

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

+
+ + + +
+ ); +} diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 1ca0bb20e8723b..55a75240909bf5 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -36,6 +36,7 @@ import { createToolBarPagerButtonsDirective, createToolBarPagerTextDirective, } from './application/angular/doc_table/components/pager'; +import { createContextAppLegacy } from './application/components/context_app/context_app_legacy_directive'; import { createTableRowDirective } from './application/angular/doc_table/components/table_row'; import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory'; import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll'; @@ -55,7 +56,6 @@ import { createContextErrorMessageDirective } from './application/components/con import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive'; - /** * returns the main inner angular module, it contains all the parts of Angular Discover * needs to render, so in the end the current 'kibana' angular module is no longer necessary @@ -190,5 +190,6 @@ function createDocTableModule() { .directive('kbnTableRow', createTableRowDirective) .directive('toolBarPagerButtons', createToolBarPagerButtonsDirective) .directive('kbnInfiniteScroll', createInfiniteScrollDirective) - .directive('docViewer', createDocViewerDirective); + .directive('docViewer', createDocViewerDirective) + .directive('contextAppLegacy', createContextAppLegacy); } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx index d9095944eaa335..a086b447994eb9 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.test.tsx @@ -133,7 +133,7 @@ describe('', () => { find('btn').simulate('click').update(); }); - expect(onFormData.mock.calls.length).toBe(1); + expect(onFormData.mock.calls.length).toBe(2); const [formDataUpdated] = onFormData.mock.calls[onFormData.mock.calls.length - 1] as Parameters< OnUpdateHandler diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts index ac141baf8fc719..de4edc1edf8735 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -22,13 +22,16 @@ import React from 'react'; import { FormData } from '../types'; import { useFormData } from '../hooks'; -interface Props { - children: (formData: FormData) => JSX.Element | null; +interface Props { + children: (formData: I) => JSX.Element | null; pathsToWatch?: string | string[]; } -export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { - const { 0: formData, 2: isReady } = useFormData({ watch: pathsToWatch }); +const FormDataProviderComp = function ({ + children, + pathsToWatch, +}: Props) { + const { 0: formData, 2: isReady } = useFormData({ watch: pathsToWatch }); if (!isReady) { // No field has mounted yet, don't render anything @@ -36,4 +39,6 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = } return children(formData); -}); +}; + +export const FormDataProvider = React.memo(FormDataProviderComp) as typeof FormDataProviderComp; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts index 3c4f9799bb1bce..812a18680d6b8e 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts @@ -107,7 +107,7 @@ export const UseArray = ({ getNewItemAtIndex, ]); - // Create a new hook field with the "hasValue" set to false so we don't use its value to build the final form data. + // Create a new hook field with the "isIncludedInOutput" set to false so we don't use its value to build the final form data. // Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle. const fieldConfigBase: FieldConfig & InternalFieldConfig = { defaultValue: fieldDefaultValue, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index dbf53a9f0a359a..1a7f8832e4a4e5 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -58,7 +58,7 @@ describe('', () => { OnUpdateHandler >; - expect(data.raw).toEqual({ + expect(data.internal).toEqual({ name: 'John', lastName: 'Snow', }); @@ -214,8 +214,8 @@ describe('', () => { expect(serializer).not.toBeCalled(); expect(formatter).not.toBeCalled(); - let formData = formHook.getFormData({ unflatten: false }); - expect(formData.name).toEqual('John-deserialized'); + const internalFormData = formHook.__getFormData$().value; + expect(internalFormData.name).toEqual('John-deserialized'); await act(async () => { form.setInputValue('myField', 'Mike'); @@ -224,9 +224,9 @@ describe('', () => { expect(formatter).toBeCalled(); // Formatters are executed on each value change expect(serializer).not.toBeCalled(); // Serializer are executed *only** when outputting the form data - formData = formHook.getFormData(); + const outputtedFormData = formHook.getFormData(); expect(serializer).toBeCalled(); - expect(formData.name).toEqual('MIKE-serialized'); + expect(outputtedFormData.name).toEqual('MIKE-serialized'); // Make sure that when we reset the form values, we don't serialize the fields serializer.mockReset(); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx index 0670220ccd0c93..6aef6d2b0d46aa 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/form_data_context.tsx @@ -22,9 +22,9 @@ import React, { createContext, useContext, useMemo } from 'react'; import { FormData, FormHook } from './types'; import { Subject } from './lib'; -export interface Context { - getFormData$: () => Subject; - getFormData: FormHook['getFormData']; +export interface Context { + getFormData$: () => Subject; + getFormData: FormHook['getFormData']; } const FormDataContext = createContext | undefined>(undefined); @@ -45,6 +45,6 @@ export const FormDataContextProvider = ({ children, getFormData$, getFormData }: return {children}; }; -export function useFormDataContext() { - return useContext | undefined>(FormDataContext); +export function useFormDataContext() { + return useContext | undefined>(FormDataContext); } diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 7b21b6638aeac7..f4f13a698ee307 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -63,6 +63,7 @@ export const useField = ( __removeField, __updateFormDataAt, __validateFields, + __getFormData$, } = form; const deserializeValue = useCallback( @@ -76,7 +77,7 @@ export const useField = ( ); const [value, setStateValue] = useState(deserializeValue); - const [errors, setErrors] = useState([]); + const [errors, setStateErrors] = useState([]); const [isPristine, setPristine] = useState(true); const [isValidating, setValidating] = useState(false); const [isChangingValue, setIsChangingValue] = useState(false); @@ -86,18 +87,12 @@ export const useField = ( const validateCounter = useRef(0); const changeCounter = useRef(0); const hasBeenReset = useRef(false); - const inflightValidation = useRef | null>(null); + const inflightValidation = useRef<(Promise & { cancel?(): void }) | null>(null); const debounceTimeout = useRef(null); + // ---------------------------------- // -- HELPERS // ---------------------------------- - const serializeValue: FieldHook['__serializeValue'] = useCallback( - (internalValue: I = value) => { - return serializer ? serializer(internalValue) : ((internalValue as unknown) as T); - }, - [serializer, value] - ); - /** * Filter an array of errors with specific validation type on them * @@ -117,6 +112,11 @@ export const useField = ( ); }; + /** + * If the field has some "formatters" defined in its config, run them in series and return + * the transformed value. This handler is called whenever the field value changes, right before + * updating the "value" state. + */ const formatInputValue = useCallback( (inputValue: unknown): T => { const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; @@ -125,11 +125,11 @@ export const useField = ( return inputValue as T; } - const formData = getFormData({ unflatten: false }); + const formData = __getFormData$().value; return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; }, - [formatters, getFormData] + [formatters, __getFormData$] ); const onValueChange = useCallback(async () => { @@ -147,7 +147,7 @@ export const useField = ( // Update the form data observable __updateFormDataAt(path, value); - // Validate field(s) (that will update form.isValid state) + // Validate field(s) (this will update the form.isValid state) await __validateFields(fieldsToValidateOnChange ?? [path]); if (isMounted.current === false) { @@ -162,15 +162,18 @@ export const useField = ( */ if (changeIteration === changeCounter.current) { if (valueChangeDebounceTime > 0) { - const delta = Date.now() - startTime; - if (delta < valueChangeDebounceTime) { + const timeElapsed = Date.now() - startTime; + + if (timeElapsed < valueChangeDebounceTime) { + const timeLeftToWait = valueChangeDebounceTime - timeElapsed; debounceTimeout.current = setTimeout(() => { debounceTimeout.current = null; setIsChangingValue(false); - }, valueChangeDebounceTime - delta); + }, timeLeftToWait); return; } } + setIsChangingValue(false); } }, [ @@ -183,41 +186,34 @@ export const useField = ( __validateFields, ]); + // Cancel any inflight validation (e.g an HTTP Request) const cancelInflightValidation = useCallback(() => { - // Cancel any inflight validation (like an HTTP Request) - if ( - inflightValidation.current && - typeof (inflightValidation.current as any).cancel === 'function' - ) { - (inflightValidation.current as any).cancel(); + if (inflightValidation.current && typeof inflightValidation.current.cancel === 'function') { + inflightValidation.current.cancel(); inflightValidation.current = null; } }, []); - const clearErrors: FieldHook['clearErrors'] = useCallback( - (validationType = VALIDATION_TYPES.FIELD) => { - setErrors((previousErrors) => filterErrors(previousErrors, validationType)); - }, - [] - ); - const runValidations = useCallback( - ({ - formData, - value: valueToValidate, - validationTypeToValidate, - }: { - formData: any; - value: I; - validationTypeToValidate?: string; - }): ValidationError[] | Promise => { + ( + { + formData, + value: valueToValidate, + validationTypeToValidate, + }: { + formData: any; + value: I; + validationTypeToValidate?: string; + }, + clearFieldErrors: FieldHook['clearErrors'] + ): ValidationError[] | Promise => { if (!validations) { return []; } // By default, for fields that have an asynchronous validation // we will clear the errors as soon as the field value changes. - clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); + clearFieldErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); cancelInflightValidation(); @@ -329,21 +325,33 @@ export const useField = ( // We first try to run the validations synchronously return runSync(); }, - [clearErrors, cancelInflightValidation, validations, getFormData, getFields, path] + [cancelInflightValidation, validations, getFormData, getFields, path] ); - // -- API // ---------------------------------- + // -- Internal API + // ---------------------------------- + const serializeValue: FieldHook['__serializeValue'] = useCallback( + (internalValue: I = value) => { + return serializer ? serializer(internalValue) : ((internalValue as unknown) as T); + }, + [serializer, value] + ); + + // ---------------------------------- + // -- Public API + // ---------------------------------- + const clearErrors: FieldHook['clearErrors'] = useCallback( + (validationType = VALIDATION_TYPES.FIELD) => { + setStateErrors((previousErrors) => filterErrors(previousErrors, validationType)); + }, + [] + ); - /** - * Validate a form field, running all its validations. - * If a validationType is provided then only that validation will be executed, - * skipping the other type of validation that might exist. - */ const validate: FieldHook['validate'] = useCallback( (validationData = {}) => { const { - formData = getFormData({ unflatten: false }), + formData = __getFormData$().value, value: valueToValidate = value, validationType, } = validationData; @@ -362,7 +370,7 @@ export const useField = ( // This is the most recent invocation setValidating(false); // Update the errors array - setErrors((prev) => { + setStateErrors((prev) => { const filteredErrors = filterErrors(prev, validationType); return [...filteredErrors, ..._validationErrors]; }); @@ -374,25 +382,23 @@ export const useField = ( }; }; - const validationErrors = runValidations({ - formData, - value: valueToValidate, - validationTypeToValidate: validationType, - }); + const validationErrors = runValidations( + { + formData, + value: valueToValidate, + validationTypeToValidate: validationType, + }, + clearErrors + ); if (Reflect.has(validationErrors, 'then')) { return (validationErrors as Promise).then(onValidationResult); } return onValidationResult(validationErrors as ValidationError[]); }, - [getFormData, value, runValidations] + [__getFormData$, value, runValidations, clearErrors] ); - /** - * Handler to change the field value - * - * @param newValue The new value to assign to the field - */ const setValue: FieldHook['setValue'] = useCallback( (newValue) => { setStateValue((prev) => { @@ -408,8 +414,8 @@ export const useField = ( [formatInputValue] ); - const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { - setErrors( + const setErrors: FieldHook['setErrors'] = useCallback((_errors) => { + setStateErrors( _errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, __isBlocking__: true, @@ -418,11 +424,6 @@ export const useField = ( ); }, []); - /** - * Form "onChange" event handler - * - * @param event Form input change event - */ const onChange: FieldHook['onChange'] = useCallback( (event) => { const newValue = {}.hasOwnProperty.call(event!.target, 'checked') @@ -485,7 +486,7 @@ export const useField = ( case 'value': return setValue(nextValue); case 'errors': - return setErrors(nextValue); + return setStateErrors(nextValue); case 'isChangingValue': return setIsChangingValue(nextValue); case 'isPristine': @@ -539,7 +540,7 @@ export const useField = ( onChange, getErrorsMessages, setValue, - setErrors: _setErrors, + setErrors, clearErrors, validate, reset, @@ -563,7 +564,7 @@ export const useField = ( onChange, getErrorsMessages, setValue, - _setErrors, + setErrors, clearErrors, validate, reset, @@ -585,7 +586,8 @@ export const useField = ( useEffect(() => { // If the field value has been reset, we don't want to call the "onValueChange()" - // as it will set the "isPristine" state to true or validate the field, which initially we don't want. + // as it will set the "isPristine" state to true or validate the field, which we don't want + // to occur right after resetting the field state. if (hasBeenReset.current) { hasBeenReset.current = false; return; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index b28c09d07fa984..9626aaa9b2459a 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -211,7 +211,13 @@ describe('useForm() hook', () => { test('should allow subscribing to the form data changes and provide a handler to build the form data', async () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { - const { form } = useForm(); + const { form } = useForm({ + serializer: (value) => ({ + user: { + name: value.user.name.toUpperCase(), + }, + }), + }); const { subscribe } = form; useEffect(() => { @@ -253,8 +259,9 @@ describe('useForm() hook', () => { OnUpdateHandler >; - expect(data.raw).toEqual({ 'user.name': 'John' }); - expect(data.format()).toEqual({ user: { name: 'John' } }); + expect(data.internal).toEqual({ user: { name: 'John' } }); + // Transform name to uppercase as decalred in our serializer func + expect(data.format()).toEqual({ user: { name: 'JOHN' } }); // As we have touched all fields, the validity went from "undefined" to "true" expect(isValid).toBe(true); }); @@ -302,10 +309,12 @@ describe('useForm() hook', () => { OnUpdateHandler >; - expect(data.raw).toEqual({ + expect(data.internal).toEqual({ title: defaultValue.title, subTitle: 'hasBeenOverridden', - 'user.name': defaultValue.user.name, + user: { + name: defaultValue.user.name, + }, }); }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index be4535fec36694..869d1fac54b1ea 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -58,8 +58,6 @@ export function useForm( return initDefaultValue(defaultValue); }, [defaultValue, initDefaultValue]); - const defaultValueDeserialized = useRef(defaultValueMemoized); - const { valueChangeDebounceTime, stripEmptyFields: doStripEmptyFields } = options ?? {}; const formOptions = useMemo( () => ({ @@ -72,26 +70,36 @@ export function useForm( const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitting, setSubmitting] = useState(false); const [isValid, setIsValid] = useState(undefined); + const fieldsRefs = useRef({}); const formUpdateSubscribers = useRef([]); const isMounted = useRef(false); + const defaultValueDeserialized = useRef(defaultValueMemoized); // formData$ is an observable we can subscribe to in order to receive live // update of the raw form data. As an observable it does not trigger any React // render(). - // The component is the one in charge of reading this observable - // and updating its state to trigger the necessary view render. - const formData$ = useRef | null>(null); + // The "useFormData()" hook is the one in charge of reading this observable + // and updating its own state that will trigger the necessary re-renders in the UI. + const formData$ = useRef | null>(null); + // ---------------------------------- // -- HELPERS // ---------------------------------- - const getFormData$ = useCallback((): Subject => { + const getFormData$ = useCallback((): Subject => { if (formData$.current === null) { - formData$.current = new Subject({} as T); + formData$.current = new Subject({}); } return formData$.current; }, []); + const updateFormData$ = useCallback( + (nextValue: FormData) => { + getFormData$().next(nextValue); + }, + [getFormData$] + ); + const fieldsToArray = useCallback<() => FieldHook[]>(() => Object.values(fieldsRefs.current), []); const getFieldsForOutput = useCallback( @@ -115,63 +123,24 @@ export function useForm( [] ); - const updateFormDataAt: FormHook['__updateFormDataAt'] = useCallback( + const updateFormDataAt: FormHook['__updateFormDataAt'] = useCallback( (path, value) => { - const _formData$ = getFormData$(); - const currentFormData = _formData$.value; + const currentFormData = getFormData$().value; if (currentFormData[path] !== value) { - _formData$.next({ ...currentFormData, [path]: value }); + updateFormData$({ ...currentFormData, [path]: value }); } - - return _formData$.value; }, - [getFormData$] + [getFormData$, updateFormData$] ); - const updateDefaultValueAt: FormHook['__updateDefaultValueAt'] = useCallback((path, value) => { - set(defaultValueDeserialized.current, path, value); - }, []); - - // -- API - // ---------------------------------- - const getFormData: FormHook['getFormData'] = useCallback( - (getDataOptions: Parameters['getFormData']>[0] = { unflatten: true }) => { - if (getDataOptions.unflatten) { - const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, { - stripEmptyFields: formOptions.stripEmptyFields, - }); - const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue()); - return serializer - ? (serializer(unflattenObject(fieldsValue) as I) as T) - : (unflattenObject(fieldsValue) as T); - } - - return Object.entries(fieldsRefs.current).reduce( - (acc, [key, field]) => ({ - ...acc, - [key]: field.value, - }), - {} as T - ); + const updateDefaultValueAt: FormHook['__updateDefaultValueAt'] = useCallback( + (path, value) => { + set(defaultValueDeserialized.current, path, value); }, - [getFieldsForOutput, formOptions.stripEmptyFields, serializer] + [] ); - const getErrors: FormHook['getErrors'] = useCallback(() => { - if (isValid === true) { - return []; - } - - return fieldsToArray().reduce((acc, field) => { - const fieldError = field.getErrorsMessages(); - if (fieldError === null) { - return acc; - } - return [...acc, fieldError]; - }, [] as string[]); - }, [isValid, fieldsToArray]); - const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; const waitForFieldsToFinishValidating = useCallback(async () => { @@ -192,13 +161,13 @@ export function useForm( }); }, [fieldsToArray]); - const validateFields: FormHook['__validateFields'] = useCallback( + const validateFields: FormHook['__validateFields'] = useCallback( async (fieldNames) => { const fieldsToValidate = fieldNames .map((name) => fieldsRefs.current[name]) .filter((field) => field !== undefined); - const formData = getFormData({ unflatten: false }); + const formData = getFormData$().value; const validationResult = await Promise.all( fieldsToValidate.map((field) => field.validate({ formData })) ); @@ -245,34 +214,13 @@ export function useForm( return { areFieldsValid, isFormValid }; }, - [getFormData, fieldsToArray] + [getFormData$, fieldsToArray] ); - const validateAllFields = useCallback(async (): Promise => { - // Maybe some field are being validated because of their async validation(s). - // We make sure those validations have finished executing before proceeding. - await waitForFieldsToFinishValidating(); - - if (!isMounted.current) { - return false; - } - - const fieldsArray = fieldsToArray(); - const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); - - let isFormValid: boolean | undefined; - - if (fieldsToValidate.length === 0) { - isFormValid = fieldsArray.every(isFieldValid); - } else { - ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); - } - - setIsValid(isFormValid); - return isFormValid!; - }, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]); - - const addField: FormHook['__addField'] = useCallback( + // ---------------------------------- + // -- Internal API + // ---------------------------------- + const addField: FormHook['__addField'] = useCallback( (field) => { fieldsRefs.current[field.path] = field; @@ -291,17 +239,17 @@ export function useForm( [updateFormDataAt] ); - const removeField: FormHook['__removeField'] = useCallback( + const removeField: FormHook['__removeField'] = useCallback( (_fieldNames) => { const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames]; - const currentFormData = { ...getFormData$().value } as FormData; + const currentFormData = { ...getFormData$().value }; fieldNames.forEach((name) => { delete fieldsRefs.current[name]; delete currentFormData[name]; }); - getFormData$().next(currentFormData as T); + updateFormData$(currentFormData); /** * After removing a field, the form validity might have changed @@ -316,40 +264,91 @@ export function useForm( return prev; }); }, - [getFormData$, fieldsToArray] + [getFormData$, updateFormData$, fieldsToArray] + ); + + const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback( + (fieldName) => get(defaultValueDeserialized.current, fieldName), + [] + ); + + const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = useCallback( + (fieldName) => { + const config = (get(schema ?? {}, fieldName) as FieldConfig) || {}; + + return config; + }, + [schema] ); - const setFieldValue: FormHook['setFieldValue'] = useCallback((fieldName, value) => { + // ---------------------------------- + // -- Public API + // ---------------------------------- + const getFormData: FormHook['getFormData'] = useCallback(() => { + const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, { + stripEmptyFields: formOptions.stripEmptyFields, + }); + const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue()); + return serializer + ? serializer(unflattenObject(fieldsValue)) + : unflattenObject(fieldsValue); + }, [getFieldsForOutput, formOptions.stripEmptyFields, serializer]); + + const getErrors: FormHook['getErrors'] = useCallback(() => { + if (isValid === true) { + return []; + } + + return fieldsToArray().reduce((acc, field) => { + const fieldError = field.getErrorsMessages(); + if (fieldError === null) { + return acc; + } + return [...acc, fieldError]; + }, [] as string[]); + }, [isValid, fieldsToArray]); + + const validate: FormHook['validate'] = useCallback(async (): Promise => { + // Maybe some field are being validated because of their async validation(s). + // We make sure those validations have finished executing before proceeding. + await waitForFieldsToFinishValidating(); + + if (!isMounted.current) { + return false; + } + + const fieldsArray = fieldsToArray(); + const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); + + let isFormValid: boolean | undefined; + + if (fieldsToValidate.length === 0) { + isFormValid = fieldsArray.every(isFieldValid); + } else { + ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); + } + + setIsValid(isFormValid); + return isFormValid!; + }, [fieldsToArray, validateFields, waitForFieldsToFinishValidating]); + + const setFieldValue: FormHook['setFieldValue'] = useCallback((fieldName, value) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setValue(value); }, []); - const setFieldErrors: FormHook['setFieldErrors'] = useCallback((fieldName, errors) => { + const setFieldErrors: FormHook['setFieldErrors'] = useCallback((fieldName, errors) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setErrors(errors); }, []); - const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - - const getFieldDefaultValue: FormHook['__getFieldDefaultValue'] = useCallback( - (fieldName) => get(defaultValueDeserialized.current, fieldName), - [] - ); - - const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = useCallback( - (fieldName) => { - const config = (get(schema ?? {}, fieldName) as FieldConfig) || {}; + const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - return config; - }, - [schema] - ); - - const submitForm: FormHook['submit'] = useCallback( + const submit: FormHook['submit'] = useCallback( async (e) => { if (e) { e.preventDefault(); @@ -358,7 +357,7 @@ export function useForm( setIsSubmitted(true); // User has attempted to submit the form at least once setSubmitting(true); - const isFormValid = await validateAllFields(); + const isFormValid = await validate(); const formData = isFormValid ? getFormData() : ({} as T); if (onSubmit) { @@ -371,13 +370,17 @@ export function useForm( return { data: formData, isValid: isFormValid! }; }, - [validateAllFields, getFormData, onSubmit] + [validate, getFormData, onSubmit] ); - const subscribe: FormHook['subscribe'] = useCallback( + const subscribe: FormHook['subscribe'] = useCallback( (handler) => { const subscription = getFormData$().subscribe((raw) => { - handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); + handler({ + isValid, + data: { internal: unflattenObject(raw), format: getFormData }, + validate, + }); }); formUpdateSubscribers.current.push(subscription); @@ -391,17 +394,13 @@ export function useForm( }, }; }, - [getFormData$, isValid, getFormData, validateAllFields] + [getFormData$, isValid, getFormData, validate] ); - /** - * Reset all the fields of the form to their default values - * and reset all the states to their original value. - */ - const reset: FormHook['reset'] = useCallback( + const reset: FormHook['reset'] = useCallback( (resetOptions = { resetValues: true }) => { const { resetValues = true, defaultValue: updatedDefaultValue } = resetOptions; - const currentFormData = { ...getFormData$().value } as FormData; + const currentFormData = { ...getFormData$().value }; if (updatedDefaultValue) { defaultValueDeserialized.current = initDefaultValue(updatedDefaultValue); @@ -417,25 +416,26 @@ export function useForm( currentFormData[path] = fieldDefaultValue; } }); + if (resetValues) { - getFormData$().next(currentFormData as T); + updateFormData$(currentFormData); } setIsSubmitted(false); setSubmitting(false); setIsValid(undefined); }, - [getFormData$, initDefaultValue, getFieldDefaultValue] + [getFormData$, updateFormData$, initDefaultValue, getFieldDefaultValue] ); - const form = useMemo>(() => { + const form = useMemo>(() => { return { isSubmitted, isSubmitting, isValid, id, - submit: submitForm, - validate: validateAllFields, + submit, + validate, subscribe, setFieldValue, setFieldErrors, @@ -458,7 +458,7 @@ export function useForm( isSubmitting, isValid, id, - submitForm, + submit, subscribe, setFieldValue, setFieldErrors, @@ -475,7 +475,7 @@ export function useForm( addField, removeField, validateFields, - validateAllFields, + validate, ]); useEffect(() => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx index 0fb65daecf2f4f..beb8e58edbf491 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.test.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { act } from 'react-dom/test-utils'; import { registerTestBed, TestBed } from '../shared_imports'; @@ -25,37 +25,59 @@ import { Form, UseField } from '../components'; import { useForm } from './use_form'; import { useFormData, HookReturn } from './use_form_data'; -interface Props { - onChange(data: HookReturn): void; +interface Props { + onChange(data: HookReturn): void; watch?: string | string[]; } +interface Form1 { + title: string; +} + +interface Form2 { + user: { + firstName: string; + lastName: string; + }; +} + +interface Form3 { + title: string; + subTitle: string; +} + describe('useFormData() hook', () => { - const HookListenerComp = React.memo(({ onChange, watch }: Props) => { - const hookValue = useFormData({ watch }); + const HookListenerComp = function ({ onChange, watch }: Props) { + const hookValue = useFormData({ watch }); + const isMounted = useRef(false); useEffect(() => { - onChange(hookValue); + if (isMounted.current) { + onChange(hookValue); + } + isMounted.current = true; }, [hookValue, onChange]); return null; - }); + }; + + const HookListener = React.memo(HookListenerComp); describe('form data updates', () => { let testBed: TestBed; let onChangeSpy: jest.Mock; const getLastMockValue = () => { - return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; }; - const TestComp = (props: Props) => { - const { form } = useForm(); + const TestComp = (props: Props) => { + const { form } = useForm(); return (
- + ); }; @@ -70,9 +92,7 @@ describe('useFormData() hook', () => { }); test('should return the form data', () => { - // Called twice: - // once when the hook is called and once when the fields have mounted and updated the form data - expect(onChangeSpy).toBeCalledTimes(2); + expect(onChangeSpy).toBeCalledTimes(1); const [data] = getLastMockValue(); expect(data).toEqual({ title: 'titleInitialValue' }); }); @@ -86,7 +106,7 @@ describe('useFormData() hook', () => { setInputValue('titleField', 'titleChanged'); }); - expect(onChangeSpy).toBeCalledTimes(3); + expect(onChangeSpy).toBeCalledTimes(2); const [data] = getLastMockValue(); expect(data).toEqual({ title: 'titleChanged' }); }); @@ -96,17 +116,17 @@ describe('useFormData() hook', () => { let onChangeSpy: jest.Mock; const getLastMockValue = () => { - return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; }; - const TestComp = (props: Props) => { - const { form } = useForm(); + const TestComp = (props: Props) => { + const { form } = useForm(); return (
- + ); }; @@ -121,8 +141,8 @@ describe('useFormData() hook', () => { }); test('should expose a handler to build the form data', () => { - const { 1: format } = getLastMockValue(); - expect(format()).toEqual({ + const [formData] = getLastMockValue(); + expect(formData).toEqual({ user: { firstName: 'John', lastName: 'Snow', @@ -137,11 +157,11 @@ describe('useFormData() hook', () => { let onChangeSpy: jest.Mock; const getLastMockValue = () => { - return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; + return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; }; - const TestComp = (props: Props) => { - const { form } = useForm(); + const TestComp = (props: Props) => { + const { form } = useForm(); return (
@@ -190,9 +210,9 @@ describe('useFormData() hook', () => { return onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1][0] as HookReturn; }; - const TestComp = ({ onChange }: Props) => { + const TestComp = ({ onChange }: Props) => { const { form } = useForm(); - const hookValue = useFormData({ form }); + const hookValue = useFormData({ form }); useEffect(() => { onChange(hookValue); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts index 6c6dee3624979d..9487e2d30c680c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data.ts @@ -19,6 +19,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { FormData, FormHook } from '../types'; +import { unflattenObject } from '../lib'; import { useFormDataContext, Context } from '../form_data_context'; interface Options { @@ -26,14 +27,16 @@ interface Options { form?: FormHook; } -export type HookReturn = [FormData, () => T, boolean]; +export type HookReturn = [I, () => T, boolean]; -export const useFormData = (options: Options = {}): HookReturn => { +export const useFormData = ( + options: Options = {} +): HookReturn => { const { watch, form } = options; - const ctx = useFormDataContext(); + const ctx = useFormDataContext(); - let getFormData: Context['getFormData']; - let getFormData$: Context['getFormData$']; + let getFormData: Context['getFormData']; + let getFormData$: Context['getFormData$']; if (form !== undefined) { getFormData = form.getFormData; @@ -50,30 +53,33 @@ export const useFormData = (options: Options = {}): const previousRawData = useRef(initialValue); const isMounted = useRef(false); - const [formData, setFormData] = useState(previousRawData.current); + const [formData, setFormData] = useState(() => unflattenObject(previousRawData.current)); - const formatFormData = useCallback(() => { - return getFormData({ unflatten: true }); - }, [getFormData]); + /** + * We do want to offer to the consumer a handler to serialize the form data that changes each time + * the formData **state** changes. This is why we added the "formData" dep to the array and added the eslint override. + */ + const serializer = useCallback(() => { + return getFormData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getFormData, formData]); useEffect(() => { const subscription = getFormData$().subscribe((raw) => { + if (!isMounted.current && Object.keys(raw).length === 0) { + return; + } + if (watch) { - const valuesToWatchArray = Array.isArray(watch) - ? (watch as string[]) - : ([watch] as string[]); + const pathsToWatchArray: string[] = Array.isArray(watch) ? watch : [watch]; - if ( - valuesToWatchArray.some( - (value) => previousRawData.current[value] !== raw[value as keyof T] - ) - ) { + if (pathsToWatchArray.some((path) => previousRawData.current[path] !== raw[path])) { previousRawData.current = raw; // Only update the state if one of the field we watch has changed. - setFormData(raw); + setFormData(unflattenObject(raw)); } } else { - setFormData(raw); + setFormData(unflattenObject(raw)); } }); return subscription.unsubscribe; @@ -88,8 +94,8 @@ export const useFormData = (options: Options = {}): if (!isMounted.current && Object.keys(formData).length === 0) { // No field has mounted yet - return [formData, formatFormData, false]; + return [formData, serializer, false]; } - return [formData, formatFormData, true]; + return [formData, serializer, true]; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts index 7d506e28794fd9..f67070c8746a1f 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/lib/utils.ts @@ -20,25 +20,11 @@ import { set } from '@elastic/safer-lodash-set'; import { FieldHook } from '../types'; -export const unflattenObject = (object: any) => +export const unflattenObject = (object: object): T => Object.entries(object).reduce((acc, [key, value]) => { set(acc, key, value); return acc; - }, {}); - -export const flattenObject = ( - object: Record, - to: Record = {}, - paths: string[] = [] -): Record => - Object.entries(object).reduce((acc, [key, value]) => { - const updatedPaths = [...paths, key]; - if (value !== null && !Array.isArray(value) && typeof value === 'object') { - return flattenObject(value, to, updatedPaths); - } - acc[updatedPaths.join('.')] = value; - return acc; - }, to); + }, {} as T); /** * Helper to map the object of fields to any of its value diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index ae731caff28818..6196ae83a84a69 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -40,33 +40,36 @@ export interface FormHook submit: (e?: FormEvent | MouseEvent) => Promise<{ data: T; isValid: boolean }>; /** Use this handler to get the validity of the form. */ validate: () => Promise; - subscribe: (handler: OnUpdateHandler) => Subscription; + subscribe: (handler: OnUpdateHandler) => Subscription; /** Sets a field value imperatively. */ setFieldValue: (fieldName: string, value: FieldValue) => void; /** Sets a field errors imperatively. */ setFieldErrors: (fieldName: string, errors: ValidationError[]) => void; - /** Access any field on the form. */ + /** Access the fields on the form. */ getFields: () => FieldsMap; /** * Return the form data. It accepts an optional options object with an `unflatten` parameter (defaults to `true`). * If you are only interested in the raw form data, pass `unflatten: false` to the handler */ - getFormData: (options?: { unflatten?: boolean }) => T; + getFormData: () => T; /* Returns an array with of all errors in the form. */ getErrors: () => string[]; - /** Resets the form to its initial state. */ + /** + * Reset the form states to their initial value and optionally + * all the fields to their initial values. + */ reset: (options?: { resetValues?: boolean; defaultValue?: Partial }) => void; readonly __options: Required; - __getFormData$: () => Subject; + __getFormData$: () => Subject; __addField: (field: FieldHook) => void; __removeField: (fieldNames: string | string[]) => void; __validateFields: ( fieldNames: string[] ) => Promise<{ areFieldsValid: boolean; isFormValid: boolean | undefined }>; - __updateFormDataAt: (field: string, value: unknown) => T; + __updateFormDataAt: (field: string, value: unknown) => void; __updateDefaultValueAt: (field: string, value: unknown) => void; - __readFieldConfigFromSchema: (fieldName: string) => FieldConfig; - __getFieldDefaultValue: (fieldName: string) => unknown; + __readFieldConfigFromSchema: (field: string) => FieldConfig; + __getFieldDefaultValue: (path: string) => unknown; } export type FormSchema = { @@ -83,16 +86,18 @@ export interface FormConfig { +export interface OnFormUpdateArg { data: { - raw: { [key: string]: any }; + internal: I; format: () => T; }; validate: () => Promise; isValid?: boolean; } -export type OnUpdateHandler = (arg: OnFormUpdateArg) => void; +export type OnUpdateHandler = ( + arg: OnFormUpdateArg +) => void; export interface FormOptions { valueChangeDebounceTime?: number; @@ -119,10 +124,26 @@ export interface FieldHook { validationType?: 'field' | string; errorCode?: string; }) => string | null; + /** + * Form "onChange" event handler + * + * @param event Form input change event + */ onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void; + /** + * Handler to change the field value + * + * @param value The new value to assign to the field. If you provide a callback, you wil receive + * the previous value and you need to return the next value. + */ setValue: (value: I | ((prevValue: I) => I)) => void; setErrors: (errors: ValidationError[]) => void; clearErrors: (type?: string | string[]) => void; + /** + * Validate a form field, running all its validations. + * If a validationType is provided then only that validation will be executed, + * skipping the other type of validation that might exist. + */ validate: (validateData?: { formData?: any; value?: I; @@ -166,19 +187,23 @@ export interface ValidationError { [key: string]: any; } -export interface ValidationFuncArg { +export interface ValidationFuncArg { path: string; value: V; form: { - getFormData: FormHook['getFormData']; - getFields: FormHook['getFields']; + getFormData: FormHook['getFormData']; + getFields: FormHook['getFields']; }; - formData: T; + formData: I; errors: readonly ValidationError[]; } -export type ValidationFunc = ( - data: ValidationFuncArg +export type ValidationFunc< + I extends FormData = FormData, + E extends string = string, + V = unknown +> = ( + data: ValidationFuncArg ) => ValidationError | void | undefined | Promise | void | undefined>; export interface FieldValidateResponse { @@ -199,11 +224,11 @@ type FormatterFunc = (value: any, formData: FormData) => unknown; type FieldValue = unknown; export interface ValidationConfig< - FormType extends FormData = any, - Error extends string = string, - ValueType = unknown + I extends FormData = FormData, + E extends string = string, + V = unknown > { - validator: ValidationFunc; + validator: ValidationFunc; type?: string; /** * By default all validation are blockers, which means that if they fail, the field is invalid. diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx index 08edf42df60d8d..545eb86311dad4 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/create_edit_field/create_edit_field.tsx @@ -96,7 +96,7 @@ export const CreateEditField = withRouter( indexPattern={indexPattern} spec={spec} services={{ - saveIndexPattern: data.indexPatterns.updateSavedObject.bind(data.indexPatterns), + indexPatternService: data.indexPatterns, redirectAway, }} /> diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap index 45253f6ad27c03..8e7fac9c6c1483 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/__snapshots__/indexed_fields_table.test.tsx.snap @@ -6,6 +6,7 @@ exports[`IndexedFieldsTable should filter based on the query bar 1`] = ` editField={[Function]} indexPattern={ Object { + "getFormatterForFieldNoDefault": [Function], "getNonScriptedFields": [Function], } } @@ -14,7 +15,7 @@ exports[`IndexedFieldsTable should filter based on the query bar 1`] = ` Object { "displayName": "Elastic", "excluded": false, - "format": undefined, + "format": "", "info": Array [], "name": "Elastic", "searchable": true, @@ -32,6 +33,7 @@ exports[`IndexedFieldsTable should filter based on the type filter 1`] = ` editField={[Function]} indexPattern={ Object { + "getFormatterForFieldNoDefault": [Function], "getNonScriptedFields": [Function], } } @@ -40,7 +42,7 @@ exports[`IndexedFieldsTable should filter based on the type filter 1`] = ` Object { "displayName": "timestamp", "excluded": false, - "format": undefined, + "format": "", "info": Array [], "name": "timestamp", "type": "date", @@ -57,6 +59,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` editField={[Function]} indexPattern={ Object { + "getFormatterForFieldNoDefault": [Function], "getNonScriptedFields": [Function], } } @@ -65,7 +68,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` Object { "displayName": "Elastic", "excluded": false, - "format": undefined, + "format": "", "info": Array [], "name": "Elastic", "searchable": true, @@ -74,7 +77,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` Object { "displayName": "timestamp", "excluded": false, - "format": undefined, + "format": "", "info": Array [], "name": "timestamp", "type": "date", @@ -82,7 +85,7 @@ exports[`IndexedFieldsTable should render normally 1`] = ` Object { "displayName": "conflictingField", "excluded": false, - "format": undefined, + "format": "", "info": Array [], "name": "conflictingField", "type": "conflict", diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx index e7ac1af7c1be8c..23f0a83c591def 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.test.tsx @@ -43,6 +43,7 @@ const helpers = { const indexPattern = ({ getNonScriptedFields: () => fields, + getFormatterForFieldNoDefault: () => ({ params: () => ({}) }), } as unknown) as IndexPattern; const mockFieldToIndexPatternField = (spec: Record) => { diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx index 7be420e2af50d0..92f0c4576e9316 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/indexed_fields_table.tsx @@ -21,7 +21,6 @@ import React, { Component } from 'react'; import { createSelector } from 'reselect'; import { IndexPatternField, IndexPattern, IFieldType } from '../../../../../../plugins/data/public'; import { Table } from './components/table'; -import { getFieldFormat } from './lib'; import { IndexedFieldItem } from './types'; interface IndexedFieldsTableProps { @@ -73,7 +72,7 @@ export class IndexedFieldsTable extends Component< return { ...field.spec, displayName: field.displayName, - format: getFieldFormat(indexPattern, field.name), + format: indexPattern.getFormatterForFieldNoDefault(field.name)?.type?.title || '', excluded: fieldWildcardMatch ? fieldWildcardMatch(field.name) : false, info: helpers.getFieldInfo && helpers.getFieldInfo(indexPattern, field), }; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts b/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts deleted file mode 100644 index 2786df641fdb28..00000000000000 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/indexed_fields_table/lib/get_field_format.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { IIndexPattern } from '../../../../../../data/public'; -import { getFieldFormat } from './get_field_format'; - -const indexPattern = ({ - fieldFormatMap: { - Elastic: { - type: { - title: 'string', - }, - }, - }, -} as unknown) as IIndexPattern; - -describe('getFieldFormat', () => { - test('should handle no arguments', () => { - expect(getFieldFormat()).toEqual(''); - }); - - test('should handle no field name', () => { - expect(getFieldFormat(indexPattern)).toEqual(''); - }); - - test('should handle empty name', () => { - expect(getFieldFormat(indexPattern, '')).toEqual(''); - }); - - test('should handle undefined field name', () => { - expect(getFieldFormat(indexPattern, 'none')).toEqual(undefined); - }); - - test('should retrieve field format', () => { - expect(getFieldFormat(indexPattern, 'Elastic')).toEqual('string'); - }); -}); diff --git a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap index 3f4190eed91707..1e8fb6f9492fe7 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/field_editor/__snapshots__/field_editor.test.tsx.snap @@ -30,6 +30,7 @@ exports[`FieldEditor should render create new scripted field correctly 1`] = ` "getByName": [Function], }, "getFormatterForField": [Function], + "getFormatterForFieldNoDefault": [Function], } } isVisible={false} @@ -265,6 +266,7 @@ exports[`FieldEditor should render edit scripted field correctly 1`] = ` "getByName": [Function], }, "getFormatterForField": [Function], + "getFormatterForFieldNoDefault": [Function], } } isVisible={false} @@ -499,6 +501,7 @@ exports[`FieldEditor should show conflict field warning 1`] = ` "getByName": [Function], }, "getFormatterForField": [Function], + "getFormatterForFieldNoDefault": [Function], } } isVisible={false} @@ -762,6 +765,7 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` "getByName": [Function], }, "getFormatterForField": [Function], + "getFormatterForFieldNoDefault": [Function], } } isVisible={false} @@ -1077,6 +1081,7 @@ exports[`FieldEditor should show multiple type field warning with a table contai "getByName": [Function], }, "getFormatterForField": [Function], + "getFormatterForFieldNoDefault": [Function], } } isVisible={false} diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.test.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.test.tsx index 23f52475d413de..8817e0f5c2c5f2 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.test.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.test.tsx @@ -17,7 +17,12 @@ * under the License. */ -import { IndexPattern, IndexPatternField, FieldFormatInstanceType } from 'src/plugins/data/public'; +import { + IndexPattern, + IndexPatternField, + FieldFormatInstanceType, + IndexPatternsService, +} from 'src/plugins/data/public'; jest.mock('brace/mode/groovy', () => ({})); @@ -94,7 +99,11 @@ const field = { format: new Format(), }; -const services = { redirectAway: () => {}, saveIndexPattern: async () => {} }; +const services = { + redirectAway: () => {}, + saveIndexPattern: async () => {}, + indexPatternService: {} as IndexPatternsService, +}; describe('FieldEditor', () => { let indexPattern: IndexPattern; @@ -115,6 +124,7 @@ describe('FieldEditor', () => { indexPattern = ({ fields, getFormatterForField: () => ({ params: () => ({}) }), + getFormatterForFieldNoDefault: () => ({ params: () => ({}) }), } as unknown) as IndexPattern; }); diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx index 4fae91e78f8f9e..d02338a6aee24b 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx @@ -133,7 +133,7 @@ export interface FieldEdiorProps { spec: IndexPatternField['spec']; services: { redirectAway: () => void; - saveIndexPattern: DataPublicPluginStart['indexPatterns']['updateSavedObject']; + indexPatternService: DataPublicPluginStart['indexPatterns']; }; } @@ -208,7 +208,7 @@ export class FieldEditor extends PureComponent { - const { uiSettings, data } = this.context.services; + const { data } = this.context.services; const { spec, format } = this.state; const DefaultFieldFormat = data.fieldFormats.getDefaultType(type) as FieldFormatInstanceType; spec.type = type; - spec.format = new DefaultFieldFormat(null, (key) => uiSettings.get(key)); - this.setState({ fieldTypeFormats: getFieldTypeFormatsList(spec, DefaultFieldFormat, data.fieldFormats), fieldFormatId: DefaultFieldFormat.id, @@ -247,7 +245,7 @@ export class FieldEditor extends PureComponent { - const { spec, fieldTypeFormats } = this.state; + const { fieldTypeFormats } = this.state; const { uiSettings, data } = this.context.services; const FieldFormat = data.fieldFormats.getType( @@ -255,11 +253,10 @@ export class FieldEditor extends PureComponent uiSettings.get(key)); - spec.format = newFormat; this.setState({ - fieldFormatId: FieldFormat.id, - fieldFormatParams: newFormat.params(), + fieldFormatId: formatId, + fieldFormatParams: params, format: newFormat, }); }; @@ -515,7 +512,7 @@ export class FieldEditor extends PureComponent { - const { redirectAway, saveIndexPattern } = this.props.services; + const { redirectAway, indexPatternService } = this.props.services; const { indexPattern } = this.props; const { spec } = this.state; indexPattern.removeScriptedField(spec.name); - saveIndexPattern(indexPattern).then(() => { + indexPatternService.updateSavedObject(indexPattern).then(() => { const message = i18n.translate('indexPatternManagement.deleteField.deletedHeader', { defaultMessage: "Deleted '{fieldName}'", values: { fieldName: spec.name }, @@ -775,7 +772,7 @@ export class FieldEditor extends PureComponent { const field = this.state.spec; const { indexPattern } = this.props; - const { fieldFormatId } = this.state; + const { fieldFormatId, fieldFormatParams } = this.state; if (field.scripted) { this.setState({ @@ -798,7 +795,7 @@ export class FieldEditor extends PureComponent { const message = i18n.translate('indexPatternManagement.deleteField.savedHeader', { defaultMessage: "Saved '{fieldName}'", diff --git a/src/plugins/kibana_legacy/tsconfig.json b/src/plugins/kibana_legacy/tsconfig.json new file mode 100644 index 00000000000000..709036c9e82f43 --- /dev/null +++ b/src/plugins/kibana_legacy/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["../../../typings/**/*", "public/**/*", "server/**/*", "config.ts"], + "references": [{ "path": "../../core/tsconfig.json" }] +} diff --git a/src/plugins/kibana_react/public/react_router_navigate/react_router_navigate.tsx b/src/plugins/kibana_react/public/react_router_navigate/react_router_navigate.tsx index 7a9fe19273324d..e127ac4d98133c 100644 --- a/src/plugins/kibana_react/public/react_router_navigate/react_router_navigate.tsx +++ b/src/plugins/kibana_react/public/react_router_navigate/react_router_navigate.tsx @@ -18,7 +18,7 @@ */ import { ScopedHistory } from 'kibana/public'; -import { History } from 'history'; +import { History, parsePath } from 'history'; interface LocationObject { pathname?: string; @@ -32,7 +32,7 @@ const isModifiedEvent = (event: any) => const isLeftClickEvent = (event: any) => event.button === 0; export const toLocationObject = (to: string | LocationObject) => - typeof to === 'string' ? { pathname: to } : to; + typeof to === 'string' ? parsePath(to) : to; export const reactRouterNavigate = ( history: ScopedHistory | History, diff --git a/src/plugins/telemetry/tsconfig.json b/src/plugins/telemetry/tsconfig.json new file mode 100644 index 00000000000000..bdced01d9eb6f0 --- /dev/null +++ b/src/plugins/telemetry/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/**/*", + "server/**/**/*", + "common/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/usage_collection/tsconfig.json" }, + { "path": "../../plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "../../plugins/kibana_utils/tsconfig.json" }, + { "path": "../../plugins/kibana_react/tsconfig.json" } + ] +} diff --git a/src/plugins/telemetry_collection_manager/tsconfig.json b/src/plugins/telemetry_collection_manager/tsconfig.json new file mode 100644 index 00000000000000..1bba81769f0dd0 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "common/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/usage_collection/tsconfig.json" } + ] +} diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 24fccbbbfed0d2..b1921452354d2d 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -1,5 +1,50 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`TelemetryManagementSectionComponent does not show the endpoint link when isSecurityExampleEnabled returns false 1`] = ` + +

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

+

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

+
+`; + exports[`TelemetryManagementSectionComponent renders as expected 1`] = ` , "endpointSecurityData": { it('renders as expected', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -52,6 +53,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={true} enableSaving={true} + isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} /> ) @@ -60,6 +62,7 @@ describe('TelemetryManagementSectionComponent', () => { it('renders null because query does not match the SEARCH_TERMS', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -83,6 +86,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={false} enableSaving={true} + isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} /> @@ -98,6 +102,7 @@ describe('TelemetryManagementSectionComponent', () => { showAppliesSettingMessage={false} enableSaving={true} toasts={coreStart.notifications.toasts} + isSecurityExampleEnabled={isSecurityExampleEnabled} /> ); @@ -110,6 +115,7 @@ describe('TelemetryManagementSectionComponent', () => { it('renders because query matches the SEARCH_TERMS', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -131,6 +137,7 @@ describe('TelemetryManagementSectionComponent', () => { telemetryService={telemetryService} onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={false} + isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} /> @@ -155,6 +162,7 @@ describe('TelemetryManagementSectionComponent', () => { it('renders null because allowChangingOptInStatus is false', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -177,6 +185,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={true} enableSaving={true} + isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} /> ); @@ -191,6 +200,7 @@ describe('TelemetryManagementSectionComponent', () => { it('shows the OptInExampleFlyout', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -213,6 +223,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={false} enableSaving={true} + isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} /> ); @@ -228,6 +239,7 @@ describe('TelemetryManagementSectionComponent', () => { it('shows the OptInSecurityExampleFlyout', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -249,6 +261,7 @@ describe('TelemetryManagementSectionComponent', () => { telemetryService={telemetryService} onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={false} + isSecurityExampleEnabled={isSecurityExampleEnabled} enableSaving={true} toasts={coreStart.notifications.toasts} /> @@ -263,8 +276,48 @@ describe('TelemetryManagementSectionComponent', () => { } }); + it('does not show the endpoint link when isSecurityExampleEnabled returns false', () => { + const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(false); + const telemetryService = new TelemetryService({ + config: { + enabled: true, + url: '', + banner: true, + allowChangingOptInStatus: true, + optIn: false, + optInStatusUrl: '', + sendUsageFrom: 'browser', + }, + reportOptInStatusChange: false, + currentKibanaVersion: 'mock_kibana_version', + notifications: coreStart.notifications, + http: coreSetup.http, + }); + + const component = mountWithIntl( + + ); + + try { + const description = (component.instance() as TelemetryManagementSection).renderDescription(); + expect(isSecurityExampleEnabled).toBeCalled(); + expect(description).toMatchSnapshot(); + } finally { + component.unmount(); + } + }); + it('toggles the OptIn button', async () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -287,6 +340,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} showAppliesSettingMessage={false} enableSaving={true} + isSecurityExampleEnabled={isSecurityExampleEnabled} toasts={coreStart.notifications.toasts} /> ); @@ -311,6 +365,7 @@ describe('TelemetryManagementSectionComponent', () => { it('test the wrapper (for coverage purposes)', () => { const onQueryMatchChange = jest.fn(); + const isSecurityExampleEnabled = jest.fn().mockReturnValue(true); const telemetryService = new TelemetryService({ config: { enabled: true, @@ -335,6 +390,7 @@ describe('TelemetryManagementSectionComponent', () => { onQueryMatchChange={onQueryMatchChange} enableSaving={true} toasts={coreStart.notifications.toasts} + isSecurityExampleEnabled={isSecurityExampleEnabled} /> ).html() ).toMatchSnapshot(); diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx index 822d8b49661c1d..c43b600597c573 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section.tsx @@ -45,6 +45,7 @@ const SEARCH_TERMS = ['telemetry', 'usage', 'data', 'usage data']; interface Props { telemetryService: TelemetryService; onQueryMatchChange: (searchTermMatches: boolean) => void; + isSecurityExampleEnabled: () => boolean; showAppliesSettingMessage: boolean; enableSaving: boolean; query?: any; @@ -89,8 +90,9 @@ export class TelemetryManagementSection extends Component { } render() { - const { telemetryService } = this.props; + const { telemetryService, isSecurityExampleEnabled } = this.props; const { showExample, showSecurityExample, queryMatches, enabled, processing } = this.state; + const securityExampleEnabled = isSecurityExampleEnabled(); if (!telemetryService.getCanChangeOptInStatus()) { return null; @@ -108,7 +110,9 @@ export class TelemetryManagementSection extends Component { onClose={this.toggleExample} /> )} - {showSecurityExample && } + {showSecurityExample && securityExampleEnabled && ( + + )} @@ -181,48 +185,63 @@ export class TelemetryManagementSection extends Component { ); }; - renderDescription = () => ( - -

- - - - ), - }} - /> -

-

- - - - ), - endpointSecurityData: ( - - - - ), - }} - /> -

-
- ); + renderDescription = () => { + const { isSecurityExampleEnabled } = this.props; + const securityExampleEnabled = isSecurityExampleEnabled(); + const clusterDataLink = ( + + + + ); + + const endpointSecurityDataLink = ( + + + + ); + + return ( + +

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

+

+ {securityExampleEnabled ? ( + + ) : ( + + )} +

+
+ ); + }; toggleOptIn = async (): Promise => { const { telemetryService, toasts } = this.props; @@ -264,6 +283,9 @@ export class TelemetryManagementSection extends Component { }; toggleSecurityExample = () => { + const { isSecurityExampleEnabled } = this.props; + const securityExampleEnabled = isSecurityExampleEnabled(); + if (!securityExampleEnabled) return; this.setState({ showSecurityExample: !this.state.showSecurityExample, }); diff --git a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx index f61268c4772a3d..95acbaba38845e 100644 --- a/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx +++ b/src/plugins/telemetry_management_section/public/components/telemetry_management_section_wrapper.tsx @@ -28,13 +28,15 @@ type Props = any; const TelemetryManagementSectionComponent = lazy(() => import('./telemetry_management_section')); export function telemetryManagementSectionWrapper( - telemetryService: TelemetryPluginSetup['telemetryService'] + telemetryService: TelemetryPluginSetup['telemetryService'], + shouldShowSecuritySolutionUsageExample: () => boolean ) { const TelemetryManagementSectionWrapper = (props: Props) => ( }> diff --git a/src/plugins/telemetry_management_section/public/index.ts b/src/plugins/telemetry_management_section/public/index.ts index 082f68809a67e5..f3aef9eca750d8 100644 --- a/src/plugins/telemetry_management_section/public/index.ts +++ b/src/plugins/telemetry_management_section/public/index.ts @@ -16,9 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -export { OptInExampleFlyout } from './components'; import { TelemetryManagementSectionPlugin } from './plugin'; +export { OptInExampleFlyout } from './components'; + +export { TelemetryManagementSectionPluginSetup } from './plugin'; export function plugin() { return new TelemetryManagementSectionPlugin(); } diff --git a/src/plugins/telemetry_management_section/public/plugin.tsx b/src/plugins/telemetry_management_section/public/plugin.tsx index 738b38c36d30d0..c200e830c8f612 100644 --- a/src/plugins/telemetry_management_section/public/plugin.tsx +++ b/src/plugins/telemetry_management_section/public/plugin.tsx @@ -38,16 +38,32 @@ export interface TelemetryManagementSectionPluginDepsSetup { advancedSettings: AdvancedSettingsSetup; } -export class TelemetryManagementSectionPlugin implements Plugin { +export interface TelemetryManagementSectionPluginSetup { + toggleSecuritySolutionExample: (enabled: boolean) => void; +} + +export class TelemetryManagementSectionPlugin + implements Plugin { + private showSecuritySolutionExample = false; + private shouldShowSecuritySolutionExample = () => { + return this.showSecuritySolutionExample; + }; + public setup( core: CoreSetup, { advancedSettings, telemetry: { telemetryService } }: TelemetryManagementSectionPluginDepsSetup ) { advancedSettings.component.register( advancedSettings.component.componentType.PAGE_FOOTER_COMPONENT, - telemetryManagementSectionWrapper(telemetryService), + telemetryManagementSectionWrapper(telemetryService, this.shouldShowSecuritySolutionExample), true ); + + return { + toggleSecuritySolutionExample: (enabled: boolean) => { + this.showSecuritySolutionExample = enabled; + }, + }; } public start(core: CoreStart) {} diff --git a/src/plugins/usage_collection/public/plugin.ts b/src/plugins/usage_collection/public/plugin.ts index 40f27f82699288..79faa9a102909f 100644 --- a/src/plugins/usage_collection/public/plugin.ts +++ b/src/plugins/usage_collection/public/plugin.ts @@ -30,7 +30,7 @@ import { } from '../../../core/public'; import { reportApplicationUsage } from './services/application_usage'; -interface PublicConfigType { +export interface PublicConfigType { uiMetric: { enabled: boolean; debug: boolean; diff --git a/src/plugins/usage_collection/tsconfig.json b/src/plugins/usage_collection/tsconfig.json new file mode 100644 index 00000000000000..96b2c4d37e17c2 --- /dev/null +++ b/src/plugins/usage_collection/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../../plugins/kibana_utils/tsconfig.json" } + ] +} diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap index e32425a0954291..88ed7c66a79a2b 100644 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap +++ b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud.test.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"foobarfoobar"`; +exports[`tag cloud tests tagcloudscreenshot should render simple image 1`] = `"foobarfoobar"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap index dbc3dd1202cbd2..d7707f64d8a4fc 100644 --- a/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap +++ b/src/plugins/vis_type_tagcloud/public/components/__snapshots__/tag_cloud_visualization.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"CNINUSDEBR"`; +exports[`TagCloudVisualizationTest TagCloudVisualization - basics simple draw 1`] = `"CNINUSDEBR"`; -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"CNINUSDEBR"`; +exports[`TagCloudVisualizationTest TagCloudVisualization - basics with param change 1`] = `"CNINUSDEBR"`; -exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"CNINUSDEBR"`; +exports[`TagCloudVisualizationTest TagCloudVisualization - basics with resize 1`] = `"CNINUSDEBR"`; diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js index e48515d2438442..b1de60f854a1c7 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud.js @@ -48,7 +48,11 @@ export class TagCloud extends EventEmitter { this.resize(); //SETTING (non-configurable) - this._fontFamily = 'Open Sans, sans-serif'; + /** + * the fontFamily should be set explicitly for calculating a layout + * and to avoid words overlapping + */ + this._fontFamily = 'Inter UI, sans-serif'; this._fontStyle = 'normal'; this._fontWeight = 'normal'; this._spiral = 'archimedean'; //layout shape diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx index 18a09ec9f49698..cb0daa6d293825 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_chart.tsx @@ -62,10 +62,10 @@ export const TagCloudChart = ({ () => throttle(() => { if (visController.current) { - visController.current.render().then(renderComplete); + visController.current.render(visData, visParams).then(renderComplete); } }, 300), - [renderComplete] + [renderComplete, visData, visParams] ); return ( diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js index 5ec22d2c6a4d9e..0d64c9d02eafd0 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.js @@ -85,11 +85,8 @@ export class TagCloudVisualization { } async render(data, visParams) { - if (data && visParams) { - this._updateParams(visParams); - this._updateData(data); - } - + this._updateParams(visParams); + this._updateData(data); this._resize(); await this._renderComplete$.pipe(take(1)).toPromise(); diff --git a/test/tsconfig.json b/test/tsconfig.json index 2c92367f84823e..ec6612d0dd00dc 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -20,6 +20,9 @@ "references": [ { "path": "../src/core/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, - { "path": "../src/plugins/kibana_react/tsconfig.json" } + { "path": "../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "../src/plugins/telemetry/tsconfig.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index cf112b26a2cbbd..4f22168b67a095 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,18 +3,17 @@ "compilerOptions": { "tsBuildInfoFile": "./build/tsbuildinfo/kibana" }, - "include": [ - "kibana.d.ts", - "src/**/*", - "typings/**/*", - "test_utils/**/*" - ], + "include": ["kibana.d.ts", "src/**/*", "typings/**/*", "test_utils/**/*"], "exclude": [ "src/**/__fixtures__/**/*", "src/test_utils/**/*", "src/core/**/*", + "src/plugins/kibana_legacy/**/*", "src/plugins/kibana_utils/**/*", - "src/plugins/kibana_react/**/*" + "src/plugins/kibana_react/**/*", + "src/plugins/usage_collection/**/*", + "src/plugins/telemetry_collection_manager/**/*", + "src/plugins/telemetry/**/*" // In the build we actually exclude **/public/**/* from this config so that // we can run the TSC on both this and the .browser version of this config // file, but if we did it during development IDEs would not be able to find @@ -25,6 +24,9 @@ { "path": "./src/test_utils/tsconfig.json" }, { "path": "./src/core/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, - { "path": "./src/plugins/kibana_react/tsconfig.json" } + { "path": "./src/plugins/kibana_react/tsconfig.json" }, + { "path": "./src/plugins/usage_collection/tsconfig.json" }, + { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "./src/plugins/telemetry/tsconfig.json" } ] } diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 54bd07e4b134ce..4b6ed4a0db8135 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -5,5 +5,8 @@ { "path": "./src/core/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, + { "path": "./src/plugins/usage_collection/tsconfig.json" }, + { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "./src/plugins/telemetry/tsconfig.json" }, ] } diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index e641b81189b93a..95e7784e51acf5 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry'; import { ActionType, ExecutorType } from './types'; import { ActionExecutor, ExecutorError, ILicenseState, TaskRunnerFactory } from './lib'; @@ -13,7 +13,7 @@ import { licenseStateMock } from './lib/license_state.mock'; import { ActionsConfigurationUtilities } from './actions_config'; import { licensingMock } from '../../licensing/server/mocks'; -const mockTaskManager = taskManagerMock.setup(); +const mockTaskManager = taskManagerMock.createSetup(); let mockedLicenseState: jest.Mocked; let mockedActionsConfig: jest.Mocked; let actionTypeRegistryParams: ActionTypeRegistryOpts; @@ -66,7 +66,6 @@ describe('register()', () => { "getRetry": [Function], "maxAttempts": 1, "title": "My action type", - "type": "actions:my-action-type", }, }, ] diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index b93d4a6e78ac66..cacf7166b96ba3 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -125,7 +125,6 @@ export class ActionTypeRegistry { this.taskManager.registerTaskDefinitions({ [`actions:${actionType.id}`]: { title: actionType.name, - type: `actions:${actionType.id}`, maxAttempts: actionType.maxAttempts || 1, getRetry(attempts: number, error: unknown) { if (error instanceof ExecutorError) { diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 2b6aec42e0d210..171f8d4b0b1d43 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -10,7 +10,7 @@ import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_regist import { ActionsClient } from './actions_client'; import { ExecutorType, ActionType } from './types'; import { ActionExecutor, TaskRunnerFactory, ILicenseState } from './lib'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; @@ -34,7 +34,7 @@ const authorization = actionsAuthorizationMock.create(); const executionEnqueuer = jest.fn(); const request = {} as KibanaRequest; -const mockTaskManager = taskManagerMock.setup(); +const mockTaskManager = taskManagerMock.createSetup(); let actionsClient: ActionsClient; let mockedLicenseState: jest.Mocked; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index f7882849708e59..a9d1e28182b291 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -6,7 +6,7 @@ import { ActionExecutor, TaskRunnerFactory } from '../lib'; import { ActionTypeRegistry } from '../action_type_registry'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; import { registerBuiltInActionTypes } from './index'; import { Logger } from '../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; @@ -22,8 +22,8 @@ export function createActionTypeRegistry(): { } { const logger = loggingSystemMock.create().get() as jest.Mocked; const actionTypeRegistry = new ActionTypeRegistry({ + taskManager: taskManagerMock.createSetup(), licensing: licensingMock.createSetup(), - taskManager: taskManagerMock.setup(), taskRunnerFactory: new TaskRunnerFactory( new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) ), diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 33e78ee444cd06..ed06bd888f9199 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -6,7 +6,7 @@ import { KibanaRequest } from 'src/core/server'; import uuid from 'uuid'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; import { createExecutionEnqueuerFunction } from './create_execute_function'; import { savedObjectsClientMock } from '../../../../src/core/server/mocks'; import { actionTypeRegistryMock } from './action_type_registry.mock'; @@ -15,7 +15,7 @@ import { asSavedObjectExecutionSource, } from './lib/action_execution_source'; -const mockTaskManager = taskManagerMock.start(); +const mockTaskManager = taskManagerMock.createStart(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts index 225f2761bad427..3ec824f955e724 100644 --- a/x-pack/plugins/actions/server/feature.ts +++ b/x-pack/plugins/actions/server/feature.ts @@ -13,8 +13,6 @@ export const ACTIONS_FEATURE = { name: i18n.translate('xpack.actions.featureRegistry.actionsFeatureName', { defaultMessage: 'Actions and Connectors', }), - icon: 'bell', - navLinkId: 'actions', category: DEFAULT_APP_CATEGORIES.management, app: [], management: { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index e2f11abeefff22..ad1c51d06d0c08 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -45,6 +45,7 @@ const createServicesMock = () => { callCluster: elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser, getLegacyScopedClusterClient: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index ef20ffbb9ee68a..668e8b849b8a72 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -426,6 +426,7 @@ export class ActionsPlugin implements Plugin, Plugi return (request) => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: getScopedClient(request), + scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient) { return clusterClient.asScoped(request); }, diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index a23a2b08932613..a8db8bfd7344cc 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -14,6 +14,7 @@ import { KibanaRequest, SavedObjectsClientContract, SavedObjectAttributes, + ElasticsearchClient, } from '../../../../src/core/server'; import { ActionTypeExecutorResult } from '../common'; export { ActionTypeExecutorResult } from '../common'; @@ -30,6 +31,7 @@ export type ActionTypeParams = Record; export interface Services { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; + scopedClusterClient: ElasticsearchClient; getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient): ILegacyScopedClusterClient; } diff --git a/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts b/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts index 2e2944aab425cc..0e6c2ff37eb029 100644 --- a/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_usage_collector.test.ts @@ -6,9 +6,9 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { registerActionsUsageCollector } from './actions_usage_collector'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; -const mockTaskManagerStart = taskManagerMock.start(); +const mockTaskManagerStart = taskManagerMock.createStart(); beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/plugins/actions/server/usage/task.ts b/x-pack/plugins/actions/server/usage/task.ts index efa695cdc2667b..f7af480aa9fb36 100644 --- a/x-pack/plugins/actions/server/usage/task.ts +++ b/x-pack/plugins/actions/server/usage/task.ts @@ -39,7 +39,6 @@ function registerActionsTelemetryTask( taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Actions usage fetch task', - type: TELEMETRY_TASK_TYPE, timeout: '5m', createTaskRunner: telemetryTaskRunner(logger, core, kibanaIndex), }, diff --git a/x-pack/plugins/alerts/server/alert_type_registry.test.ts b/x-pack/plugins/alerts/server/alert_type_registry.test.ts index 048cc3d5a44402..9e1545bae53845 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.test.ts @@ -7,9 +7,9 @@ import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; import { AlertType } from './types'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; -const taskManager = taskManagerMock.setup(); +const taskManager = taskManagerMock.createSetup(); const alertTypeRegistryParams = { taskManager, taskRunnerFactory: new TaskRunnerFactory(), @@ -118,7 +118,6 @@ describe('register()', () => { "alerting:test": Object { "createTaskRunner": [Function], "title": "Test", - "type": "alerting:test", }, }, ] diff --git a/x-pack/plugins/alerts/server/alert_type_registry.ts b/x-pack/plugins/alerts/server/alert_type_registry.ts index 7f34803b05a810..0cd218571035ab 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.ts @@ -86,7 +86,6 @@ export class AlertTypeRegistry { this.taskManager.registerTaskDefinitions({ [`alerting:${alertType.id}`]: { title: alertType.name, - type: `alerting:${alertType.id}`, createTaskRunner: (context: RunContext) => this.taskRunnerFactory.create({ ...alertType } as AlertType, context), }, diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index 56e868732e3fb4..bce1af203fb0e3 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -6,7 +6,7 @@ import { schema } from '@kbn/config-schema'; import { AlertsClient, ConstructorOptions, CreateOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -16,7 +16,7 @@ import { ActionsAuthorization, ActionsClient } from '../../../../actions/server' import { TaskStatus } from '../../../../task_manager/server'; import { getBeforeSetup, setGlobalDate } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts index 1ebd9fc296b13f..d9b253c3a56e81 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts index 2dd3da07234ce3..d0557df6220284 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts index b214d8ba697b1d..f098bbcad8d050 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -15,7 +15,7 @@ import { ActionsAuthorization } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index bf55a2070d8fe2..c1adaddc80d9e0 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { nodeTypes } from '../../../../../../src/plugins/data/common'; @@ -16,7 +16,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup, setGlobalDate } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 327a1fa23ef05d..004230403de2e8 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup, setGlobalDate } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts index 09212732b76e79..a53e49337f3857 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_instance_summary.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -19,7 +19,7 @@ import { EventsFactory } from '../../lib/alert_instance_summary_from_event_log.t import { RawAlert } from '../../types'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const eventLogClient = eventLogClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_state.test.ts index 42e573aea347f0..8b32f05f6d5a1f 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get_alert_state.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { TaskStatus } from '../../../../task_manager/server'; @@ -15,7 +15,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts b/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts index 96e49e21b90459..5ebb4e90d4b508 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/lib.ts @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TaskManager } from '../../../../task_manager/server/task_manager'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { IEventLogClient } from '../../../../event_log/server'; import { actionsClientMock } from '../../../../actions/server/mocks'; import { ConstructorOptions } from '../alerts_client'; @@ -41,9 +40,7 @@ export function setGlobalDate() { export function getBeforeSetup( alertsClientParams: jest.Mocked, - taskManager: jest.Mocked< - Pick - >, + taskManager: ReturnType, alertTypeRegistry: jest.Mocked>, eventLogClient?: jest.Mocked ) { diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts index 4337ed6c491d4b..b2f5c5498f848c 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/list_alert_types.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts index 44ee6713f2560c..88199dfd1f7b9a 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts index dc9a1600a57764..cd7112b3551b36 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts index 45920db105c2aa..07666c1cc6261e 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts index 56040115011301..97711b8c14579a 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index 60b5b62954f05d..1dcde6addb9bfb 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -7,7 +7,7 @@ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { IntervalSchedule } from '../../types'; @@ -19,7 +19,7 @@ import { ActionsAuthorization, ActionsClient } from '../../../../actions/server' import { TaskStatus } from '../../../../task_manager/server'; import { getBeforeSetup, setGlobalDate } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts index 97ddfa5e4adb46..1f3b567b2c0312 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts @@ -5,7 +5,7 @@ */ import { AlertsClient, ConstructorOptions } from '../alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { alertsAuthorizationMock } from '../../authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; @@ -14,7 +14,7 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { getBeforeSetup } from './lib'; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); diff --git a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts index 1c5edb45c80fe3..b1ac5ac4c6783e 100644 --- a/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_conflict_retries.test.ts @@ -8,7 +8,7 @@ import { cloneDeep } from 'lodash'; import { AlertsClient, ConstructorOptions } from './alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { alertsAuthorizationMock } from './authorization/alerts_authorization.mock'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; @@ -25,7 +25,7 @@ const MockAlertId = 'alert-id'; const ConflictAfterRetries = RetryForConflictsAttempts + 1; -const taskManager = taskManagerMock.start(); +const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index d747efbb959d8e..55c2f3ddd18a41 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -7,7 +7,7 @@ import { Request } from 'hapi'; import { AlertsClientFactory, AlertsClientFactoryOpts } from './alerts_client_factory'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../task_manager/server/mocks'; import { KibanaRequest } from '../../../../src/core/server'; import { savedObjectsClientMock, @@ -35,7 +35,7 @@ const features = featuresPluginMock.createStart(); const securityPluginSetup = securityMock.createSetup(); const alertsClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), - taskManager: taskManagerMock.start(), + taskManager: taskManagerMock.createStart(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), getSpace: jest.fn(), diff --git a/x-pack/plugins/alerts/server/mocks.ts b/x-pack/plugins/alerts/server/mocks.ts index c39aa13b580fcd..05d64bdbb77f4f 100644 --- a/x-pack/plugins/alerts/server/mocks.ts +++ b/x-pack/plugins/alerts/server/mocks.ts @@ -61,6 +61,7 @@ const createAlertServicesMock = () => { callCluster: elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser, getLegacyScopedClusterClient: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), + scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, }; }; export type AlertServicesMock = ReturnType; diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 4cdcac4c9e889f..03302d5e6e7db3 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -302,6 +302,7 @@ export class AlertingPlugin { return (request) => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: this.getScopedClientWithAlertSavedObjectType(savedObjects, request), + scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient) { return clusterClient.asScoped(request); }, diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 03d41724213ce6..74153d1ca6b1df 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -10,6 +10,7 @@ import { PluginSetupContract, PluginStartContract } from './plugin'; import { AlertsClient } from './alerts_client'; export * from '../common'; import { + ElasticsearchClient, ILegacyClusterClient, ILegacyScopedClusterClient, KibanaRequest, @@ -45,6 +46,7 @@ declare module 'src/core/server' { export interface Services { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; + scopedClusterClient: ElasticsearchClient; getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient): ILegacyScopedClusterClient; } diff --git a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts index b48d173ba36d9c..a5f83bc393d4ec 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_usage_collector.test.ts @@ -6,8 +6,8 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { registerAlertsUsageCollector } from './alerts_usage_collector'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; -const taskManagerStart = taskManagerMock.start(); +import { taskManagerMock } from '../../../task_manager/server/mocks'; +const taskManagerStart = taskManagerMock.createStart(); beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/plugins/alerts/server/usage/task.ts b/x-pack/plugins/alerts/server/usage/task.ts index daf3ac246adadb..24ac15bbea78c5 100644 --- a/x-pack/plugins/alerts/server/usage/task.ts +++ b/x-pack/plugins/alerts/server/usage/task.ts @@ -42,7 +42,6 @@ function registerAlertingTelemetryTask( taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Alerting usage fetch task', - type: TELEMETRY_TASK_TYPE, timeout: '5m', createTaskRunner: telemetryTaskRunner(logger, core, kibanaIndex), }, diff --git a/x-pack/plugins/apm/e2e/README.md b/x-pack/plugins/apm/e2e/README.md index cf29b14d19541f..3517c74e950c71 100644 --- a/x-pack/plugins/apm/e2e/README.md +++ b/x-pack/plugins/apm/e2e/README.md @@ -2,8 +2,14 @@ **Run E2E tests** + ```sh +# In one terminal +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/plugins/apm/e2e/ci/kibana.e2e.yml +# In another terminal x-pack/plugins/apm/e2e/run-e2e.sh ``` -_Starts APM Server, Elasticsearch (with sample data) and runs the tests_ +Starts kibana, APM Server, Elasticsearch (with sample data) and runs the tests. + +If you see errors about not all events being ingested correctly try running `cd kibana/x-pack/plugins/apm/e2e/tmp/apm-integration-testing && docker-compose down -v` diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts index 55c980d5edeb40..314254883b2fd0 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts @@ -12,9 +12,7 @@ When('the user changes the selected percentile', () => { // wait for all loading to finish cy.get('kbnLoadingIndicator').should('not.be.visible'); - getDataTestSubj('uxPercentileSelect').click(); - - getDataTestSubj('p95Percentile').click(); + getDataTestSubj('uxPercentileSelect').select('95'); }); Then(`it displays client metric related to that percentile`, () => { @@ -22,8 +20,5 @@ Then(`it displays client metric related to that percentile`, () => { verifyClientMetrics(metrics, false); - // reset to median - getDataTestSubj('uxPercentileSelect').click(); - - getDataTestSubj('p50Percentile').click(); + getDataTestSubj('uxPercentileSelect').select('50'); }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index 310c01291aea49..0b4dcea5d12e00 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -22,6 +22,24 @@ const ClFlexGroup = styled(EuiFlexGroup)` } `; +function formatTitle(unit: string, value?: number) { + if (typeof value === 'undefined') return I18LABELS.dataMissing; + return formatToSec(value, unit); +} + +function PageViewsTotalTitle({ pageViews }: { pageViews?: number }) { + if (typeof pageViews === 'undefined') { + return <>{I18LABELS.dataMissing}; + } + return pageViews < 10000 ? ( + <>{numeral(pageViews).format('0,0')} + ) : ( + + <>{numeral(pageViews).format('0 a')} + + ); +} + export function ClientMetrics() { const uxQuery = useUxQuery(); @@ -50,14 +68,12 @@ export function ClientMetrics() { const STAT_STYLE = { width: '240px' }; - const pageViewsTotal = data?.pageViews?.value ?? 0; - return ( @@ -65,7 +81,7 @@ export function ClientMetrics() { @@ -73,15 +89,7 @@ export function ClientMetrics() { - <>{numeral(pageViewsTotal).format('0 a')} - - ) - } + title={} description={I18LABELS.pageViews} isLoading={status !== 'success'} /> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index f30a3ea5fb2dd0..793c9619edb3db 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexItem, EuiStat, EuiFlexGroup } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { + DATA_UNDEFINED_LABEL, FCP_LABEL, LONGEST_LONG_TASK, NO_OF_LONG_TASK, @@ -36,6 +37,11 @@ interface Props { loading: boolean; } +function formatTitle(unit: string, value?: number) { + if (typeof value === 'undefined') return DATA_UNDEFINED_LABEL; + return formatToSec(value, unit); +} + export function KeyUXMetrics({ data, loading }: Props) { const uxQuery = useUxQuery(); @@ -62,7 +68,7 @@ export function KeyUXMetrics({ data, loading }: Props) { @@ -70,7 +76,7 @@ export function KeyUXMetrics({ data, loading }: Props) { @@ -78,7 +84,11 @@ export function KeyUXMetrics({ data, loading }: Props) { @@ -86,7 +96,7 @@ export function KeyUXMetrics({ data, loading }: Props) { @@ -94,7 +104,7 @@ export function KeyUXMetrics({ data, loading }: Props) { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts index e6d8f881bee577..8f3a71f669ecf2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/translations.ts @@ -6,6 +6,13 @@ import { i18n } from '@kbn/i18n'; +export const DATA_UNDEFINED_LABEL = i18n.translate( + 'xpack.apm.rum.coreVitals.dataUndefined', + { + defaultMessage: 'N/A', + } +); + export const FCP_LABEL = i18n.translate('xpack.apm.rum.coreVitals.fcp', { defaultMessage: 'First contentful paint', }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx index 75a018afa13d7e..18cd7d79cc69f2 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UserPercentile/index.tsx @@ -6,19 +6,14 @@ import React, { useCallback, useEffect } from 'react'; -import { EuiSuperSelect } from '@elastic/eui'; +import { EuiSelect } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; import { I18LABELS } from '../translations'; const DEFAULT_P = 50; -const StyledSpan = styled.span` - font-weight: 600; -`; - export function UserPercentile() { const history = useHistory(); @@ -49,32 +44,29 @@ export function UserPercentile() { const options = [ { value: '50', - inputDisplay: I18LABELS.percentile50thMedian, + text: I18LABELS.percentile50thMedian, dropdownDisplay: I18LABELS.percentile50thMedian, 'data-test-subj': 'p50Percentile', }, { value: '75', - inputDisplay: {I18LABELS.percentile75th}, + text: I18LABELS.percentile75th, dropdownDisplay: I18LABELS.percentile75th, 'data-test-subj': 'p75Percentile', }, { value: '90', - inputDisplay: {I18LABELS.percentile90th}, - dropdownDisplay: I18LABELS.percentile90th, + text: I18LABELS.percentile90th, 'data-test-subj': 'p90Percentile', }, { value: '95', - inputDisplay: {I18LABELS.percentile95th}, - dropdownDisplay: I18LABELS.percentile95th, + text: I18LABELS.percentile95th, 'data-test-subj': 'p95Percentile', }, { value: '99', - inputDisplay: {I18LABELS.percentile99th}, - dropdownDisplay: I18LABELS.percentile99th, + text: I18LABELS.percentile99th, 'data-test-subj': 'p99Percentile', }, ]; @@ -84,13 +76,12 @@ export function UserPercentile() { }; return ( - onChange(value)} + onChange={(evt) => onChange(evt.target.value)} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx index 1198c014f59219..77afe92a8f5210 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/EmbeddedMap.tsx @@ -20,7 +20,7 @@ import { ViewMode, isErrorEmbeddable, } from '../../../../../../../../src/plugins/embeddable/public'; -import { getLayerList } from './LayerList'; +import { useLayerList } from './useLayerList'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { RenderTooltipContentParams } from '../../../../../../maps/public'; import { MapToolTip } from './MapToolTip'; @@ -55,6 +55,8 @@ export function EmbeddedMapComponent() { const mapFilters = useMapFilters(); + const layerList = useLayerList(); + const [embeddable, setEmbeddable] = useState< MapEmbeddable | ErrorEmbeddable | undefined >(); @@ -148,7 +150,7 @@ export function EmbeddedMapComponent() { if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { embeddableObject.setRenderTooltipContent(renderTooltipContent); - await embeddableObject.setLayerList(getLayerList()); + await embeddableObject.setLayerList(layerList); } setEmbeddable(embeddableObject); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx index 07b40addedec39..467e31f15a8de4 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/MapToolTip.tsx @@ -18,7 +18,7 @@ import { REGION_NAME, TRANSACTION_DURATION_COUNTRY, TRANSACTION_DURATION_REGION, -} from './LayerList'; +} from './useLayerList'; import { RenderTooltipContentParams } from '../../../../../../maps/public'; import { I18LABELS } from '../translations'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx index 023f5d61a964e0..1053dd611d519a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__stories__/MapTooltip.stories.tsx @@ -8,7 +8,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { EuiThemeProvider } from '../../../../../../../observability/public'; import { MapToolTip } from '../MapToolTip'; -import { COUNTRY_NAME, TRANSACTION_DURATION_COUNTRY } from '../LayerList'; +import { COUNTRY_NAME, TRANSACTION_DURATION_COUNTRY } from '../useLayerList'; storiesOf('app/RumDashboard/VisitorsRegionMap', module) .addDecorator((storyFn) => {storyFn()}) diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts index c45f8b27d7d3e8..e5643325833759 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/__mocks__/regions_layer.mock.ts @@ -25,6 +25,11 @@ export const mockLayerList = [ id: '3657625d-17b0-41ef-99ba-3a2b2938655c', indexPatternTitle: 'apm-*', term: 'client.geo.country_iso_code', + whereQuery: { + language: 'kuery', + query: + 'transaction.type : "page-load" and service.name : "undefined"', + }, metrics: [ { type: 'avg', @@ -95,6 +100,11 @@ export const mockLayerList = [ id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', indexPatternTitle: 'apm-*', term: 'client.geo.region_iso_code', + whereQuery: { + language: 'kuery', + query: + 'transaction.type : "page-load" and service.name : "undefined"', + }, metrics: [{ type: 'avg', field: 'transaction.duration.us' }], indexPatternId: 'apm_static_index_pattern_id', }, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/useLayerList.test.ts similarity index 51% rename from x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts rename to x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/useLayerList.test.ts index eb149ee2a132d0..872553452b2637 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/LayerList.test.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/useLayerList.test.ts @@ -4,14 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { renderHook } from '@testing-library/react-hooks'; import { mockLayerList } from './__mocks__/regions_layer.mock'; -import { getLayerList } from '../LayerList'; +import { useLayerList } from '../useLayerList'; -describe('LayerList', () => { - describe('getLayerList', () => { - test('it returns the region layer', () => { - const layerList = getLayerList(); - expect(layerList).toStrictEqual(mockLayerList); - }); +describe('useLayerList', () => { + test('it returns the region layer', () => { + const { result } = renderHook(() => useLayerList()); + expect(result.current).toStrictEqual(mockLayerList); }); }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts similarity index 79% rename from x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts rename to x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts index 138a3f4018c651..bc45d58329f496 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/LayerList.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts @@ -22,8 +22,14 @@ import { } from '../../../../../../maps/common/constants'; import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../../../../src/plugins/apm_oss/public'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { + SERVICE_NAME, + TRANSACTION_TYPE, +} from '../../../../../common/elasticsearch_fieldnames'; +import { TRANSACTION_PAGE_LOAD } from '../../../../../common/transaction_types'; -const ES_TERM_SOURCE: ESTermSourceDescriptor = { +const ES_TERM_SOURCE_COUNTRY: ESTermSourceDescriptor = { type: 'ES_TERM_SOURCE', id: '3657625d-17b0-41ef-99ba-3a2b2938655c', indexPatternTitle: 'apm-*', @@ -39,6 +45,26 @@ const ES_TERM_SOURCE: ESTermSourceDescriptor = { applyGlobalQuery: true, }; +const ES_TERM_SOURCE_REGION: ESTermSourceDescriptor = { + type: 'ES_TERM_SOURCE', + id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', + indexPatternTitle: 'apm-*', + term: 'client.geo.region_iso_code', + metrics: [{ type: AGG_TYPE.AVG, field: 'transaction.duration.us' }], + whereQuery: { + query: 'transaction.type : "page-load"', + language: 'kuery', + }, + indexPatternId: APM_STATIC_INDEX_PATTERN_ID, +}; + +const getWhereQuery = (serviceName: string) => { + return { + query: `${TRANSACTION_TYPE} : "${TRANSACTION_PAGE_LOAD}" and ${SERVICE_NAME} : "${serviceName}"`, + language: 'kuery', + }; +}; + export const REGION_NAME = 'region_name'; export const COUNTRY_NAME = 'name'; @@ -56,7 +82,11 @@ interface VectorLayerDescriptor extends BaseVectorLayerDescriptor { sourceDescriptor: EMSFileSourceDescriptor; } -export function getLayerList() { +export function useLayerList() { + const { urlParams } = useUrlParams(); + + const { serviceName } = urlParams; + const baseLayer: LayerDescriptor = { sourceDescriptor: { type: 'EMS_TMS', isAutoSelect: true }, id: 'b7af286d-2580-4f47-be93-9653d594ce7e', @@ -69,6 +99,8 @@ export function getLayerList() { type: 'VECTOR_TILE', }; + ES_TERM_SOURCE_COUNTRY.whereQuery = getWhereQuery(serviceName!); + const getLayerStyle = (fieldName: string): VectorStyleDescriptor => { return { type: 'VECTOR', @@ -119,7 +151,7 @@ export function getLayerList() { joins: [ { leftField: 'iso2', - right: ES_TERM_SOURCE, + right: ES_TERM_SOURCE_COUNTRY, }, ], sourceDescriptor: { @@ -138,18 +170,13 @@ export function getLayerList() { type: 'VECTOR', }; + ES_TERM_SOURCE_REGION.whereQuery = getWhereQuery(serviceName!); + const pageLoadDurationByAdminRegionLayer: VectorLayerDescriptor = { joins: [ { leftField: 'region_iso_code', - right: { - type: 'ES_TERM_SOURCE', - id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', - indexPatternTitle: 'apm-*', - term: 'client.geo.region_iso_code', - metrics: [{ type: AGG_TYPE.AVG, field: 'transaction.duration.us' }], - indexPatternId: APM_STATIC_INDEX_PATTERN_ID, - }, + right: ES_TERM_SOURCE_REGION, }, ], sourceDescriptor: { @@ -166,6 +193,7 @@ export function getLayerList() { visible: true, type: 'VECTOR', }; + return [ baseLayer, pageLoadDurationByCountryLayer, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts index c8db011874a894..a8c4d67305c989 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/translations.ts @@ -7,6 +7,9 @@ import { i18n } from '@kbn/i18n'; export const I18LABELS = { + dataMissing: i18n.translate('xpack.apm.rum.dashboard.dataMissing', { + defaultMessage: 'N/A', + }), backEnd: i18n.translate('xpack.apm.rum.dashboard.backend', { defaultMessage: 'Backend', }), diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 01336a0e8f0ce4..7e18132e59cf31 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -22,7 +22,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(),zoom:(from:now%2Fw,to:now-4h))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:())"` ); }); it('should produce the correct URL with jobId, serviceName, and transactionType', async () => { @@ -41,7 +41,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)),zoom:(from:now%2Fw,to:now-4h))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` ); }); @@ -61,7 +61,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)),zoom:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))"` + `"/app/ml/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts index a758f266b44171..0f671fd363c75a 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts @@ -38,7 +38,6 @@ export function useTimeSeriesExplorerHref({ refreshPaused !== undefined && refreshInterval !== undefined ? { pause: refreshPaused, value: refreshInterval } : undefined, - zoom: timeRange, ...(serviceName && transactionType ? { entities: { diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index e64357c0852095..8aec4184f924d6 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -17,7 +17,7 @@ import { callApmApi } from '../../../../services/rest/createCallApmApi'; // @ts-expect-error import CustomPlot from '../CustomPlot'; -const tickFormatY = (y?: number) => { +const tickFormatY = (y?: number | null) => { return asPercent(y || 0, 1); }; @@ -56,7 +56,7 @@ export function ErroneousTransactionsRateChart() { [syncedChartsProps] ); - const errorRates = data?.erroneousTransactionsRate || []; + const errorRates = data?.transactionErrorRate || []; const maxRate = max(errorRates.map((errorRate) => errorRate.y)); return ( diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index d597b65040ce60..9eba18d44ad502 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -20,8 +20,6 @@ export const APM_FEATURE = { }), order: 900, category: DEFAULT_APP_CATEGORIES.observability, - icon: 'apmApp', - navLinkId: 'apm', app: ['apm', 'ux', 'kibana'], catalogue: ['apm'], management: { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index c93fdfc15fe3ce..62fc16fb25053f 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -49,7 +49,6 @@ export async function createApmTelemetry({ taskManager.registerTaskDefinitions({ [APM_TELEMETRY_TASK_NAME]: { title: 'Collect APM usage', - type: APM_TELEMETRY_TASK_NAME, createTaskRunner: () => { return { run: async () => { diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts new file mode 100644 index 00000000000000..ccc7ba7e22b504 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EVENT_OUTCOME } from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; +import { + AggregationOptionsByType, + AggregationResultOf, +} from '../../../typings/elasticsearch/aggregations'; +import { getTransactionDurationFieldForAggregatedTransactions } from './aggregated_transactions'; + +export function getOutcomeAggregation({ + searchAggregatedTransactions, +}: { + searchAggregatedTransactions: boolean; +}) { + return { + terms: { field: EVENT_OUTCOME }, + aggs: { + count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + }, + }; +} + +export function calculateTransactionErrorPercentage( + outcomeResponse: AggregationResultOf< + ReturnType, + {} + > +) { + const outcomes = Object.fromEntries( + outcomeResponse.buckets.map(({ key, count }) => [key, count.value]) + ); + + const failedTransactions = outcomes[EventOutcome.failure] ?? 0; + const successfulTransactions = outcomes[EventOutcome.success] ?? 0; + + return failedTransactions / (successfulTransactions + failedTransactions); +} + +export function getTransactionErrorRateTimeSeries( + buckets: AggregationResultOf< + { + date_histogram: AggregationOptionsByType['date_histogram']; + aggs: { outcomes: ReturnType }; + }, + {} + >['buckets'] +) { + return buckets.map((dateBucket) => { + return { + x: dateBucket.key, + y: calculateTransactionErrorPercentage(dateBucket.outcomes), + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index eb2ddbf38b2746..f7ca40ef1052c8 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -44,7 +44,7 @@ describe('getServiceMapServiceNodeInfo', () => { it('returns data', async () => { jest.spyOn(getErrorRateModule, 'getErrorRate').mockResolvedValueOnce({ average: 0.5, - erroneousTransactionsRate: [], + transactionErrorRate: [], noHits: false, }); diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index 65bc3f7e47171e..7d190c5b664501 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -28,7 +28,11 @@ import { getMLJobIds, getServiceAnomalies, } from '../../service_map/get_service_anomalies'; -import { AggregationResultOf } from '../../../../typings/elasticsearch/aggregations'; +import { + calculateTransactionErrorPercentage, + getOutcomeAggregation, + getTransactionErrorRateTimeSeries, +} from '../../helpers/transaction_error_rate'; function getDateHistogramOpts(start: number, end: number) { return { @@ -261,20 +265,7 @@ export const getTransactionErrorRates = async ({ }: AggregationParams) => { const { apmEventClient, start, end } = setup; - const outcomes = { - terms: { - field: EVENT_OUTCOME, - }, - aggs: { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }; + const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); const response = await apmEventClient.search( mergeProjection(projection, { @@ -326,21 +317,6 @@ export const getTransactionErrorRates = async ({ return []; } - function calculateTransactionErrorPercentage( - outcomeResponse: AggregationResultOf - ) { - const successfulTransactions = - outcomeResponse.buckets.find( - (bucket) => bucket.key === EventOutcome.success - )?.count.value ?? 0; - const failedTransactions = - outcomeResponse.buckets.find( - (bucket) => bucket.key === EventOutcome.failure - )?.count.value ?? 0; - - return failedTransactions / (successfulTransactions + failedTransactions); - } - return aggregations.services.buckets.map((serviceBucket) => { const transactionErrorRate = calculateTransactionErrorPercentage( serviceBucket.outcomes @@ -349,12 +325,9 @@ export const getTransactionErrorRates = async ({ serviceName: serviceBucket.key as string, transactionErrorRate: { value: transactionErrorRate, - timeseries: serviceBucket.timeseries.buckets.map((dateBucket) => { - return { - x: dateBucket.key, - y: calculateTransactionErrorPercentage(dateBucket.outcomes), - }; - }), + timeseries: getTransactionErrorRateTimeSeries( + serviceBucket.timeseries.buckets + ), }, }; }); diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index d5289430b2698d..e9d273dad6262b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -3,21 +3,25 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { mean } from 'lodash'; -import { EventOutcome } from '../../../common/event_outcome'; + +import { Coordinate } from '../../../typings/timeseries'; + import { + EVENT_OUTCOME, + SERVICE_NAME, TRANSACTION_NAME, TRANSACTION_TYPE, - SERVICE_NAME, - EVENT_OUTCOME, } from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; import { rangeFilter } from '../../../common/utils/range_filter'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../helpers/aggregated_transactions'; + calculateTransactionErrorPercentage, + getOutcomeAggregation, + getTransactionErrorRateTimeSeries, +} from '../helpers/transaction_error_rate'; export async function getErrorRate({ serviceName, @@ -31,7 +35,11 @@ export async function getErrorRate({ transactionName?: string; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; -}) { +}): Promise<{ + noHits: boolean; + transactionErrorRate: Coordinate[]; + average: number | null; +}> { const { start, end, esFilter, apmEventClient } = setup; const transactionNamefilter = transactionName @@ -52,6 +60,8 @@ export async function getErrorRate({ ...esFilter, ]; + const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); + const params = { apm: { events: [ @@ -64,7 +74,8 @@ export async function getErrorRate({ size: 0, query: { bool: { filter } }, aggs: { - total_transactions: { + outcomes, + timeseries: { date_histogram: { field: '@timestamp', fixed_interval: getBucketSize(start, end).intervalString, @@ -72,20 +83,7 @@ export async function getErrorRate({ extended_bounds: { min: start, max: end }, }, aggs: { - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, - }, - aggs: { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }, + outcomes, }, }, }, @@ -96,31 +94,17 @@ export async function getErrorRate({ const noHits = resp.hits.total.value === 0; - const erroneousTransactionsRate = - resp.aggregations?.total_transactions.buckets.map((bucket) => { - const successful = - bucket[EVENT_OUTCOME].buckets.find( - (eventOutcomeBucket) => - eventOutcomeBucket.key === EventOutcome.success - )?.count.value ?? 0; - - const failed = - bucket[EVENT_OUTCOME].buckets.find( - (eventOutcomeBucket) => - eventOutcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0; + if (!resp.aggregations) { + return { noHits, transactionErrorRate: [], average: null }; + } - return { - x: bucket.key, - y: failed / (successful + failed), - }; - }) || []; + const transactionErrorRate = getTransactionErrorRateTimeSeries( + resp.aggregations.timeseries.buckets + ); - const average = mean( - erroneousTransactionsRate - .map((errorRate) => errorRate.y) - .filter((y) => isFinite(y)) + const average = calculateTransactionErrorPercentage( + resp.aggregations.outcomes ); - return { noHits, erroneousTransactionsRate, average }; + return { noHits, transactionErrorRate, average }; } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index ac5392c9d3dee2..02499eae7eb670 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -43,8 +43,6 @@ export class CanvasPlugin implements Plugin { name: 'Canvas', order: 300, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'canvasApp', - navLinkId: 'canvas', app: ['canvas', 'kibana'], catalogue: ['canvas'], privileges: { diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 1c3a770da79f55..45005f3f5e4227 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -22,6 +22,7 @@ interface CloudSetupDependencies { export interface CloudSetup { cloudId?: string; + cloudDeploymentUrl?: string; isCloudEnabled: boolean; } @@ -33,7 +34,7 @@ export class CloudPlugin implements Plugin { } public async setup(core: CoreSetup, { home }: CloudSetupDependencies) { - const { id, resetPasswordUrl } = this.config; + const { id, resetPasswordUrl, deploymentUrl } = this.config; const isCloudEnabled = getIsCloudEnabled(id); if (home) { @@ -45,6 +46,7 @@ export class CloudPlugin implements Plugin { return { cloudId: id, + cloudDeploymentUrl: deploymentUrl, isCloudEnabled, }; } diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts index 5b634fe4cf26c1..3360248ebb34e2 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.test.ts @@ -98,6 +98,18 @@ describe('EQL search strategy', () => { expect(mockEqlSearch).not.toHaveBeenCalled(); expect(requestParams).toEqual(expect.objectContaining({ id: 'my-search-id' })); }); + + it('emits an error if the client throws', async () => { + expect.assertions(1); + mockEqlSearch.mockReset().mockRejectedValueOnce(new Error('client error')); + const eqlSearch = await eqlSearchStrategyProvider(mockLogger); + eqlSearch.search({ options, params }, {}, mockContext).subscribe( + () => {}, + (err) => { + expect(err).toEqual(new Error('client error')); + } + ); + }); }); describe('arguments', () => { diff --git a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts index a7ca999699e237..f8dd1dca740b7e 100644 --- a/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/eql_search_strategy.ts @@ -29,48 +29,53 @@ export const eqlSearchStrategyProvider = ( }, search: (request, options, context) => from( - new Promise(async (resolve) => { + new Promise(async (resolve, reject) => { logger.debug(`_eql/search ${JSON.stringify(request.params) || request.id}`); let promise: TransportRequestPromise; - const eqlClient = context.core.elasticsearch.client.asCurrentUser.eql; - const uiSettingsClient = await context.core.uiSettings.client; - const asyncOptions = getAsyncOptions(); - const searchOptions = toSnakeCase({ ...request.options }); - if (request.id) { - promise = eqlClient.get( - { - id: request.id, - ...toSnakeCase(asyncOptions), - }, - searchOptions - ); - } else { - const { ignoreThrottled, ignoreUnavailable } = await getDefaultSearchParams( - uiSettingsClient - ); - const searchParams = toSnakeCase({ - ignoreThrottled, - ignoreUnavailable, - ...asyncOptions, - ...request.params, - }); + try { + const eqlClient = context.core.elasticsearch.client.asCurrentUser.eql; + const uiSettingsClient = await context.core.uiSettings.client; + const asyncOptions = getAsyncOptions(); + const searchOptions = toSnakeCase({ ...request.options }); - promise = eqlClient.search( - searchParams as EqlSearchStrategyRequest['params'], - searchOptions - ); - } + if (request.id) { + promise = eqlClient.get( + { + id: request.id, + ...toSnakeCase(asyncOptions), + }, + searchOptions + ); + } else { + const { ignoreThrottled, ignoreUnavailable } = await getDefaultSearchParams( + uiSettingsClient + ); + const searchParams = toSnakeCase({ + ignoreThrottled, + ignoreUnavailable, + ...asyncOptions, + ...request.params, + }); + + promise = eqlClient.search( + searchParams as EqlSearchStrategyRequest['params'], + searchOptions + ); + } - const rawResponse = await shimAbortSignal(promise, options?.abortSignal); - const { id, is_partial: isPartial, is_running: isRunning } = rawResponse.body; + const rawResponse = await shimAbortSignal(promise, options?.abortSignal); + const { id, is_partial: isPartial, is_running: isRunning } = rawResponse.body; - resolve({ - id, - isPartial, - isRunning, - rawResponse, - }); + resolve({ + id, + isPartial, + isRunning, + rawResponse, + }); + } catch (e) { + reject(e); + } }) ), }; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 43b0be8a5b438f..4b1040de52f66b 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -85,7 +85,6 @@ export class EnterpriseSearchPlugin implements Plugin { name: ENTERPRISE_SEARCH_PLUGIN.NAME, order: 0, category: DEFAULT_APP_CATEGORIES.enterpriseSearch, - icon: 'logoEnterpriseSearch', app: [ 'kibana', ENTERPRISE_SEARCH_PLUGIN.ID, diff --git a/x-pack/plugins/features/common/kibana_feature.ts b/x-pack/plugins/features/common/kibana_feature.ts index 32a75029567288..04b40843eb6d0f 100644 --- a/x-pack/plugins/features/common/kibana_feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -6,6 +6,7 @@ import { RecursiveReadonly } from '@kbn/utility-types'; import { AppCategory } from 'src/core/types'; +import { LicenseType } from '../../licensing/common/types'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; import { SubFeatureConfig, SubFeature as KibanaSubFeature } from './sub_feature'; import { ReservedKibanaPrivilege } from './reserved_kibana_privilege'; @@ -52,25 +53,12 @@ export interface KibanaFeatureConfig { excludeFromBasePrivileges?: boolean; /** - * Optional array of supported licenses. + * Optional minimum supported license. * If omitted, all licenses are allowed. * This does not restrict access to your feature based on license. * Its only purpose is to inform the space and roles UIs on which features to display. */ - validLicenses?: ReadonlyArray< - 'basic' | 'standard' | 'gold' | 'platinum' | 'enterprise' | 'trial' - >; - - /** - * An optional EUI Icon to be used when displaying your feature. - */ - icon?: string; - - /** - * The optional Nav Link ID for feature. - * If specified, your link will be automatically hidden if needed based on the current space and user permissions. - */ - navLinkId?: string; + minimumLicense?: LicenseType; /** * An array of app ids that are enabled when this feature is enabled. @@ -170,10 +158,6 @@ export class KibanaFeature { return this.config.category; } - public get navLinkId() { - return this.config.navLinkId; - } - public get app() { return this.config.app; } @@ -186,12 +170,8 @@ export class KibanaFeature { return this.config.management; } - public get icon() { - return this.config.icon; - } - - public get validLicenses() { - return this.config.validLicenses; + public get minimumLicense() { + return this.config.minimumLicense; } public get privileges() { diff --git a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap index fdeb53dd2fa125..3043de27534f1c 100644 --- a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap @@ -12,12 +12,6 @@ exports[`FeatureRegistry Kibana Features prevents features from being registered exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains_invalid()_chars" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" is not allowed to be empty]"`; - -exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "contains space" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; - -exports[`FeatureRegistry Kibana Features prevents features from being registered with a navLinkId of "contains_invalid()_chars" 1`] = `"child \\"navLinkId\\" fails because [\\"navLinkId\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]"`; - exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "catalogue" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "doesn't match valid regex" 1`] = `"child \\"id\\" fails because [\\"id\\" with value \\"doesn't match valid regex\\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]"`; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index aaaeccbd15e724..fda72e45369397 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -33,11 +33,9 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', excludeFromBasePrivileges: true, - icon: 'addDataApp', - navLinkId: 'someNavLink', app: ['app1'], category: { id: 'foo', label: 'foo' }, - validLicenses: ['standard', 'basic', 'gold', 'platinum'], + minimumLicense: 'platinum', catalogue: ['foo'], management: { foo: ['bar'], @@ -421,20 +419,6 @@ describe('FeatureRegistry', () => { }); ['contains space', 'contains_invalid()_chars', ''].forEach((prohibitedChars) => { - it(`prevents features from being registered with a navLinkId of "${prohibitedChars}"`, () => { - const featureRegistry = new FeatureRegistry(); - expect(() => - featureRegistry.registerKibanaFeature({ - id: 'foo', - name: 'some feature', - navLinkId: prohibitedChars, - app: [], - category: { id: 'foo', label: 'foo' }, - privileges: null, - }) - ).toThrowErrorMatchingSnapshot(); - }); - it(`prevents features from being registered with a management id of "${prohibitedChars}"`, () => { const featureRegistry = new FeatureRegistry(); expect(() => diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index c6ec2d52c6d1ae..78ffcdb0873604 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -91,12 +91,14 @@ const kibanaFeatureSchema = Joi.object({ category: appCategorySchema, order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), - validLicenses: Joi.array().items( - Joi.string().valid('basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial') + minimumLicense: Joi.string().valid( + 'basic', + 'standard', + 'gold', + 'platinum', + 'enterprise', + 'trial' ), - icon: Joi.string(), - description: Joi.string(), - navLinkId: Joi.string().regex(uiCapabilitiesRegex), app: Joi.array().items(Joi.string()).required(), management: managementSchema, catalogue: catalogueSchema, diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 4cec44d6fa19a3..d420aea49c6e13 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -21,8 +21,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 100, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'discoverApp', - navLinkId: 'discover', app: ['discover', 'kibana'], catalogue: ['discover'], privileges: { @@ -82,8 +80,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 700, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'visualizeApp', - navLinkId: 'visualize', app: ['visualize', 'lens', 'kibana'], catalogue: ['visualize'], privileges: { @@ -143,8 +139,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 200, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'dashboardApp', - navLinkId: 'dashboards', app: ['dashboards', 'kibana'], catalogue: ['dashboard'], privileges: { @@ -222,8 +216,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 1300, category: DEFAULT_APP_CATEGORIES.management, - icon: 'devToolsApp', - navLinkId: 'dev_tools', app: ['dev_tools', 'kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], privileges: { @@ -260,7 +252,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 1500, category: DEFAULT_APP_CATEGORIES.management, - icon: 'advancedSettingsApp', app: ['kibana'], catalogue: ['advanced_settings'], management: { @@ -300,7 +291,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 1600, category: DEFAULT_APP_CATEGORIES.management, - icon: 'indexPatternApp', app: ['kibana'], catalogue: ['indexPatterns'], management: { @@ -340,7 +330,6 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }), order: 1700, category: DEFAULT_APP_CATEGORIES.management, - icon: 'savedObjectsApp', app: ['kibana'], catalogue: ['saved_objects'], management: { @@ -384,8 +373,6 @@ const timelionFeature: KibanaFeatureConfig = { name: 'Timelion', order: 350, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'timelionApp', - navLinkId: 'timelion', app: ['timelion', 'kibana'], catalogue: ['timelion'], privileges: { diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index 692a8892031317..7080f18906146d 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -55,7 +55,7 @@ describe('GET /api/features', () => { name: 'Licensed Feature', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, - validLicenses: ['gold'], + minimumLicense: 'gold', privileges: null, }); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index b5a4203d7a7685..1b0cd207753526 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -33,10 +33,9 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) .filter( (feature) => request.query.ignoreValidLicenses || - !feature.validLicenses || - !feature.validLicenses.length || - (context.licensing!.license.type && - feature.validLicenses.includes(context.licensing!.license.type)) + !feature.minimumLicense || + (context.licensing!.license && + context.licensing!.license.hasAtLeast(feature.minimumLicense)) ) .sort( (f1, f2) => diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index f5ba17a632c926..ffb36840a58502 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -92,7 +92,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: { @@ -146,7 +145,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, catalogue: ['anotherFooEntry', 'anotherBarEntry'], @@ -216,7 +214,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: { @@ -247,7 +244,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -292,7 +288,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: { @@ -364,7 +359,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'newFeature', name: 'my new feature', - navLinkId: 'newFeatureNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: { @@ -385,7 +379,6 @@ describe('populateUICapabilities', () => { new KibanaFeature({ id: 'yetAnotherNewFeature', name: 'yet another new feature', - navLinkId: 'yetAnotherNavLink', app: ['bar-app'], category: { id: 'foo', label: 'foo' }, privileges: { diff --git a/x-pack/plugins/graph/server/plugin.ts b/x-pack/plugins/graph/server/plugin.ts index 21c50bf82f4bcf..85f25542b2efbb 100644 --- a/x-pack/plugins/graph/server/plugin.ts +++ b/x-pack/plugins/graph/server/plugin.ts @@ -49,11 +49,9 @@ export class GraphPlugin implements Plugin { }), order: 600, category: DEFAULT_APP_CATEGORIES.kibana, - icon: 'graphApp', - navLinkId: 'graph', app: ['graph', 'kibana'], catalogue: ['graph'], - validLicenses: ['platinum', 'enterprise', 'trial'], + minimumLicense: 'platinum', privileges: { all: { app: ['graph', 'kibana'], diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 9d31480fb42c7c..73116956097f16 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -809,7 +809,7 @@ describe('edit policy', () => { httpRequestsMockHelpers.setPoliciesResponse(policies); }); - describe('with legacy data role config', () => { + describe('with deprecated data role config', () => { test('should hide data tier option on cloud using legacy node role configuration', async () => { http.setupNodeListResponse({ nodesByAttributes: { test: ['123'] }, @@ -847,6 +847,8 @@ describe('edit policy', () => { expect(findTestSubject(rendered, 'defaultDataAllocationOption').exists()).toBeTruthy(); expect(findTestSubject(rendered, 'customDataAllocationOption').exists()).toBeTruthy(); expect(findTestSubject(rendered, 'noneDataAllocationOption').exists()).toBeTruthy(); + // We should not be showing the call-to-action for users to activate data tiers in cloud + expect(findTestSubject(rendered, 'cloudDataTierCallout').exists()).toBeFalsy(); }); test('should show cloud notice when cold tier nodes do not exist', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/cloud_data_tier_callout.tsx index 2dff55ac10de18..fc87b553ba5214 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/cloud_data_tier_callout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/cloud_data_tier_callout.tsx @@ -5,22 +5,50 @@ */ import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent } from 'react'; -import { EuiCallOut } from '@elastic/eui'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; + +import { useKibana } from '../../../../../shared_imports'; + +const deployment = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.body.elasticDeploymentLink', + { + defaultMessage: 'deployment', + } +); const i18nTexts = { - title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title', { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.coldTierTitle', { defaultMessage: 'Create a cold tier', }), - body: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.body', { - defaultMessage: 'Edit your Elastic Cloud deployment to set up a cold tier.', - }), + body: (deploymentUrl?: string) => { + return ( + + {deployment} +
+ ) : ( + deployment + ), + }} + /> + ); + }, }; export const CloudDataTierCallout: FunctionComponent = () => { + const { + services: { cloud }, + } = useKibana(); + return ( - {i18nTexts.body} + {i18nTexts.body(cloud?.cloudDeploymentUrl)} ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase.tsx index 3911925751ceb9..da6c358aa67c15 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase.tsx @@ -7,6 +7,7 @@ import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; @@ -56,10 +57,12 @@ export const ColdPhase: FunctionComponent = ({ errors, isShowingErrors, }) => { - const [{ [useRolloverPath]: hotPhaseRolloverEnabled }] = useFormData({ + const [formData] = useFormData({ watch: [useRolloverPath], }); + const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + return (
<> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx index 59e4738657be4a..78ae66327654c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx @@ -5,6 +5,7 @@ */ import React, { FunctionComponent, Fragment } from 'react'; +import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; @@ -48,10 +49,12 @@ export const DeletePhase: FunctionComponent = ({ isShowingErrors, getUrlForApp, }) => { - const [{ [useRolloverPath]: hotPhaseRolloverEnabled }] = useFormData({ - watch: [useRolloverPath], + const [formData] = useFormData({ + watch: useRolloverPath, }); + const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + return (
{ - const [{ [useRolloverPath]: isRolloverEnabled }] = useFormData({ watch: [useRolloverPath] }); const form = useFormContext(); + const [formData] = useFormData({ + watch: useRolloverPath, + }); + const isRolloverEnabled = get(formData, useRolloverPath); const isShowingErrors = form.isValid === false; const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_legacy_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_legacy_field.tsx index 6dffca9ed358c3..611c26d7926c3b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_legacy_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_legacy_field.tsx @@ -60,20 +60,19 @@ export const DataTierAllocationFieldLegacy: FunctionComponent = ({ switch (phaseData.dataTierAllocationType) { case 'default': const isCloudEnabled = cloud?.isCloudEnabled ?? false; - const isUsingNodeRoles = !isUsingDeprecatedDataRoleConfig; - if ( - isCloudEnabled && - isUsingNodeRoles && - phase === 'cold' && - !nodesByRoles.data_cold?.length - ) { - // Tell cloud users they can deploy cold tier nodes. - return ( - <> - - - - ); + if (isCloudEnabled && phase === 'cold') { + const isUsingNodeRolesAllocation = !isUsingDeprecatedDataRoleConfig; + const hasNoNodesWithNodeRole = !nodesByRoles.data_cold?.length; + + if (isUsingNodeRolesAllocation && hasNoNodesWithNodeRole) { + // Tell cloud users they can deploy nodes on cloud. + return ( + <> + + + + ); + } } const allocationNodeRole = getAvailableNodeRoleForPhase(phase, nodesByRoles); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index d03b36fceefdd0..7b1a4f44b5de63 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -7,6 +7,7 @@ import React, { Fragment, FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { EuiTextColor, @@ -50,16 +51,15 @@ const warmProperty: keyof Phases = 'warm'; export const WarmPhase: FunctionComponent = () => { const { originalPolicy } = useEditPolicyContext(); const form = useFormContext(); - const [ - { - [useRolloverPath]: hotPhaseRolloverEnabled, - '_meta.warm.enabled': enabled, - '_meta.warm.warmPhaseOnRollover': warmPhaseOnRollover, - }, - ] = useFormData({ + const [formData] = useFormData({ watch: [useRolloverPath, '_meta.warm.enabled', '_meta.warm.warmPhaseOnRollover'], }); + + const enabled = get(formData, '_meta.warm.enabled'); + const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + const warmPhaseOnRollover = get(formData, '_meta.warm.warmPhaseOnRollover'); const isShowingErrors = form.isValid === false; + return (
<> diff --git a/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx index f818f49d8aa592..8e8514d1b51653 100644 --- a/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx +++ b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx @@ -125,9 +125,9 @@ export const SimulateTemplateFlyoutContent = ({ - + > {(formData) => { - return ; + return ; }} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/date_range_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/date_range_datatype.test.tsx new file mode 100644 index 00000000000000..3e1a8ae8f698ee --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/date_range_datatype.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +// Parameters automatically added to the date range datatype when saved (with the default values) +export const defaultDateRangeParameters = { + type: 'date_range', + coerce: true, + index: true, + store: false, +}; + +describe('Mappings editor: date range datatype', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + let testBed: MappingsEditorTestBed; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + test('should require a scaling factor to be provided', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'double_range', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + find, + exists, + actions: { startEditField, updateFieldAndCloseFlyout, toggleFormRow }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + + expect(exists('formatParameter')).toBe(false); + + // Change the type to "date_range" + await act(async () => { + find('mappingsEditorFieldEdit.fieldSubType').simulate('change', [ + { + label: 'Date range', + value: 'date_range', + }, + ]); + }); + component.update(); + + expect(exists('formatParameter')).toBe(true); + expect(exists('formatParameter.formatInput')).toBe(false); + toggleFormRow('formatParameter'); + expect(exists('formatParameter.formatInput')).toBe(true); + + await act(async () => { + find('formatParameter.formatInput').simulate('change', [{ label: 'customDateFormat' }]); + }); + component.update(); + + await updateFieldAndCloseFlyout(); + + // It should have the default parameters values added, plus the scaling factor + updatedMappings.properties.myField = { + ...defaultDateRangeParameters, + format: 'customDateFormat', + } as any; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx new file mode 100644 index 00000000000000..117695a43e6b4e --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +// Parameters automatically added to the scaled float datatype when saved (with the default values) +export const defaultScaledFloatParameters = { + type: 'scaled_float', + coerce: true, + doc_values: true, + ignore_malformed: false, + index: true, + store: false, +}; + +describe('Mappings editor: scaled float datatype', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + let testBed: MappingsEditorTestBed; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + test('should require a scaling factor to be provided', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'byte', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + find, + exists, + form, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + + // Change the type to "scaled_float" + await act(async () => { + find('mappingsEditorFieldEdit.fieldSubType').simulate('change', [ + { + label: 'Scaled float', + value: 'scaled_float', + }, + ]); + }); + component.update(); + + // It should **not** allow to save as the "scaling factor" parameter has not been set + await updateFieldAndCloseFlyout(); + expect(exists('mappingsEditorFieldEdit')).toBe(true); + expect(form.getErrorsMessages()).toEqual(['A scaling factor is required.']); + + await act(async () => { + form.setInputValue('scalingFactor.input', '123'); + }); + await updateFieldAndCloseFlyout(); + expect(exists('mappingsEditorFieldEdit')).toBe(false); + + // It should have the default parameters values added, plus the scaling factor + updatedMappings.properties.myField = { + ...defaultScaledFloatParameters, + scaling_factor: 123, + } as any; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 2eb56a97dc3a01..a625cc8c0ab4bb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -358,9 +358,11 @@ export type TestSubjects = | 'toggleExpandButton' | 'createFieldForm' | 'createFieldForm.fieldType' + | 'createFieldForm.fieldSubType' | 'createFieldForm.addButton' | 'mappingsEditorFieldEdit' | 'mappingsEditorFieldEdit.fieldType' + | 'mappingsEditorFieldEdit.fieldSubType' | 'mappingsEditorFieldEdit.editFieldUpdateButton' | 'mappingsEditorFieldEdit.flyoutTitle' | 'mappingsEditorFieldEdit.documentationLink' @@ -383,4 +385,7 @@ export type TestSubjects = | 'searchQuoteAnalyzer-custom.input' | 'useSameAnalyzerForSearchCheckBox.input' | 'metaParameterEditor' + | 'scalingFactor.input' + | 'formatParameter' + | 'formatParameter.formatInput' | string; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx index c5001740c26c66..cbb39b70f965f5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/dynamic_mapping_section/dynamic_mapping_section.tsx @@ -54,9 +54,7 @@ export const DynamicMappingSection = () => ( {(formData) => { const { - 'dynamicMapping.enabled': enabled, - // eslint-disable-next-line @typescript-eslint/naming-convention - 'dynamicMapping.date_detection': dateDetection, + dynamicMapping: { enabled, date_detection: dateDetection }, } = formData; if (enabled === undefined) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx index d195f1abfc4443..aafdc7e58898d4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/source_field_section/source_field_section.tsx @@ -155,7 +155,9 @@ export const SourceFieldSection = () => { > {(formData) => { - const { 'sourceField.enabled': enabled } = formData; + const { + sourceField: { enabled }, + } = formData; if (enabled === undefined) { return null; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx index c966df82fb5072..eb5af5eed46a05 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/analyzer_parameter_selects.tsx @@ -53,7 +53,7 @@ export const AnalyzerParameterSelects = ({ useEffect(() => { const subscription = subscribe((updateData) => { - const formData = updateData.data.raw; + const formData = updateData.data.internal; const value = formData.sub ? formData.sub : formData.main; onChange(value); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/format_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/format_parameter.tsx index c2fe37559ae517..69e7a0e88bb166 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/format_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/format_parameter.tsx @@ -55,6 +55,7 @@ export const FormatParameter = ({ defaultValue, defaultToggleValue }: Props) => href: documentationService.getFormatLink(), }} defaultToggleValue={defaultToggleValue} + data-test-subj="formatParameter" > {(formatField) => { @@ -81,6 +82,7 @@ export const FormatParameter = ({ defaultValue, defaultToggleValue }: Props) => setComboBoxOptions([...comboBoxOptions, newOption]); }} fullWidth + data-test-subj="formatInput" /> ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/subtype_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/subtype_parameter.tsx index cfa8b60653d4c0..9ad0f25d2a4ec8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/subtype_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/subtype_parameter.tsx @@ -85,6 +85,7 @@ export const SubTypeParameter = ({ selectedOptions={subTypeField.value as ComboBoxOption[]} onChange={subTypeField.setValue} isClearable={false} + data-test-subj="fieldSubType" /> ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx index ce349b2c6104fc..0a03344a662ecc 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_form_row.tsx @@ -5,6 +5,7 @@ */ import React, { useState } from 'react'; +import { get } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, @@ -193,7 +194,7 @@ export const EditFieldFormRow = React.memo( return formFieldPath ? ( {(formData) => { - setIsContentVisible(formData[formFieldPath]); + setIsContentVisible(get(formData, formFieldPath)); return renderContent(); }} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx index 6ad3c9c5d0bd4c..2bdb6f10d65f37 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NormalizedField, Field as FieldType } from '../../../../types'; +import { NormalizedField, Field as FieldType, ComboBoxOption } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { UseField, FormDataProvider, NumericField, Field } from '../../../../shared_imports'; import { @@ -48,9 +48,9 @@ export const NumericType = ({ field }: Props) => { <> {/* scaling_factor */} - - {(formData) => - formData.subType === 'scaled_float' ? ( + pathsToWatch="subType"> + {(formData) => { + return formData.subType?.[0]?.value === 'scaled_float' ? ( { path="scaling_factor" config={getFieldConfig('scaling_factor')} component={Field} + data-test-subj="scalingFactor" /> - ) : null - } + ) : null; + }} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx index 9a37f55ac8e9d2..b4bab6d35af34f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx @@ -5,7 +5,12 @@ */ import React from 'react'; -import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { + NormalizedField, + Field as FieldType, + ParameterName, + ComboBoxOption, +} from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { StoreParameter, @@ -33,9 +38,9 @@ export const RangeType = ({ field }: Props) => { - + pathsToWatch="subType"> {(formData) => - formData.subType === 'date_range' ? ( + formData.subType?.[0]?.value === 'date_range' ? ( { - + pathsToWatch="subType"> {(formData) => - formData.subType === 'date_range' ? ( + formData.subType?.[0]?.value === 'date_range' ? ( ) : null } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx index a402dec250056d..4912b0963bc121 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx @@ -18,7 +18,7 @@ export const StateProvider: React.FC = ({ children }) => { configuration: { defaultValue: {}, data: { - raw: {}, + internal: {}, format: () => ({}), }, validate: () => Promise.resolve(true), @@ -26,7 +26,7 @@ export const StateProvider: React.FC = ({ children }) => { templates: { defaultValue: {}, data: { - raw: {}, + internal: {}, format: () => ({}), }, validate: () => Promise.resolve(true), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index e7efd6f28343bc..47e9d5200ea084 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -176,7 +176,7 @@ export const reducer = (state: State, action: Action): State => { configuration: { ...state.configuration, data: { - raw: action.value.configuration, + internal: action.value.configuration, format: () => action.value.configuration, }, defaultValue: action.value.configuration, @@ -184,7 +184,7 @@ export const reducer = (state: State, action: Action): State => { templates: { ...state.templates, data: { - raw: action.value.templates, + internal: action.value.templates, format: () => action.value.templates, }, defaultValue: action.value.templates, @@ -217,7 +217,7 @@ export const reducer = (state: State, action: Action): State => { isValid: true, defaultValue: action.value, data: { - raw: action.value, + internal: action.value, format: () => action.value, }, validate: async () => true, @@ -241,7 +241,7 @@ export const reducer = (state: State, action: Action): State => { isValid: true, defaultValue: action.value, data: { - raw: action.value, + internal: action.value, format: () => action.value, }, validate: async () => true, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index feba79ce85e85f..8d039475f9cf8f 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -35,8 +35,8 @@ export const useMappingsStateListener = ({ onChange, value }: Args) => { const isFieldFormVisible = state.fieldForm !== undefined; const emptyNameValue = isFieldFormVisible && - (state.fieldForm!.data.raw.name === undefined || - state.fieldForm!.data.raw.name.trim() === ''); + (state.fieldForm!.data.internal.name === undefined || + state.fieldForm!.data.internal.name.trim() === ''); const bypassFieldFormValidation = state.documentFields.status === 'creatingField' && emptyNameValue; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index 56f040fc59a7b1..89e857eec0bb30 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -157,7 +157,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( getFormData, } = form; - const [{ addMeta }] = useFormData({ + const [{ addMeta }] = useFormData<{ addMeta: boolean }>({ form, watch: 'addMeta', }); diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index 3767144a1b7984..d49755b6f68c16 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -17,8 +17,6 @@ export const METRICS_FEATURE = { }), order: 800, category: DEFAULT_APP_CATEGORIES.observability, - icon: 'metricsApp', - navLinkId: 'metrics', app: ['infra', 'metrics', 'kibana'], catalogue: ['infraops', 'metrics'], management: { @@ -68,8 +66,6 @@ export const LOGS_FEATURE = { }), order: 700, category: DEFAULT_APP_CATEGORIES.observability, - icon: 'logsApp', - navLinkId: 'logs', app: ['infra', 'logs', 'kibana'], catalogue: ['infralogging', 'logs'], alerting: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index e3757b46ed715e..e5a06b7e38131f 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -181,9 +181,7 @@ export class IngestManagerPlugin deps.features.registerKibanaFeature({ id: PLUGIN_ID, name: 'Ingest Manager', - icon: 'savedObjectsApp', category: DEFAULT_APP_CATEGORIES.management, - navLinkId: PLUGIN_ID, app: [PLUGIN_ID, 'kibana'], catalogue: ['ingestManager'], privileges: { diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts index 311b3bbf7f13ba..645ae8880fa61c 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts @@ -132,6 +132,8 @@ export const createAgentPolicyHandler: RequestHandler< }); } + await agentPolicyService.createFleetPolicyChangeAction(soClient, agentPolicy.id); + const body: CreateAgentPolicyResponse = { item: agentPolicy, }; @@ -185,6 +187,7 @@ export const copyAgentPolicyHandler: RequestHandler< user: user || undefined, } ); + const body: CopyAgentPolicyResponse = { item: agentPolicy }; return response.ok({ body, diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 8f1ece923f126f..9d85a151efbbf9 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -6,6 +6,7 @@ import { SavedObjectsServiceSetup, SavedObjectsType } from 'kibana/server'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; +import { migratePackagePolicyToV7110 } from '../../../security_solution/common'; import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_POLICY_SAVED_OBJECT_TYPE, @@ -268,6 +269,7 @@ const getSavedObjectTypes = ( }, migrations: { '7.10.0': migratePackagePolicyToV7100, + '7.11.0': migratePackagePolicyToV7110, }, }, [PACKAGES_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts index 75c16df483a76e..31bf3e72077748 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_policy.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_policy.ts @@ -34,6 +34,7 @@ import { agentPolicyUpdateEventHandler } from './agent_policy_update'; import { getSettings } from './settings'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { getFullAgentPolicyKibanaConfig } from '../../common/services/full_agent_policy_kibana_config'; +import { isAgentsSetup } from './agents/setup'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -287,6 +288,8 @@ class AgentPolicyService { throw new Error('Copied agent policy not found'); } + await this.createFleetPolicyChangeAction(soClient, newAgentPolicy.id); + return updatedAgentPolicy; } @@ -433,6 +436,11 @@ class AgentPolicyService { soClient: SavedObjectsClientContract, agentPolicyId: string ) { + // If Agents is not setup skip the creation of POLICY_CHANGE agent actions + // the action will be created during the fleet setup + if (!(await isAgentsSetup(soClient))) { + return; + } const policy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); if (!policy || !policy.revision) { return; diff --git a/x-pack/plugins/ingest_manager/server/services/agent_policy_update.ts b/x-pack/plugins/ingest_manager/server/services/agent_policy_update.ts index 4ddb3cfb6a6a52..fe06de765bbff6 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_policy_update.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_policy_update.ts @@ -6,8 +6,7 @@ import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; import { generateEnrollmentAPIKey, deleteEnrollmentApiKeyForAgentPolicyId } from './api_keys'; -import { unenrollForAgentPolicyId } from './agents'; -import { outputService } from './output'; +import { isAgentsSetup, unenrollForAgentPolicyId } from './agents'; import { agentPolicyService } from './agent_policy'; import { appContextService } from './app_context'; @@ -31,11 +30,8 @@ export async function agentPolicyUpdateEventHandler( action: string, agentPolicyId: string ) { - const adminUser = await outputService.getAdminUser(soClient); - const outputId = await outputService.getDefaultOutputId(soClient); - - // If no admin user and no default output fleet is not enabled just skip this hook - if (!adminUser || !outputId) { + // If Agents are not setup skip this hook + if (!(await isAgentsSetup(soClient))) { return; } diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts index c878b666bde886..0800a04fb1b3ba 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -16,3 +16,4 @@ export * from './update'; export * from './actions'; export * from './reassign'; export * from './authenticate'; +export * from './setup'; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/setup.ts b/x-pack/plugins/ingest_manager/server/services/agents/setup.ts new file mode 100644 index 00000000000000..1393e732e89d10 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/setup.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'src/core/server'; +import { SO_SEARCH_LIMIT } from '../../constants'; +import { agentPolicyService } from '../agent_policy'; +import { outputService } from '../output'; +import { getLatestConfigChangeAction } from './actions'; + +export async function isAgentsSetup(soClient: SavedObjectsClientContract): Promise { + const adminUser = await outputService.getAdminUser(soClient, false); + const outputId = await outputService.getDefaultOutputId(soClient); + // If admin user (fleet_enroll) and output id exist Agents are correctly setup + return adminUser && outputId ? true : false; +} + +/** + * During the migration from 7.9 to 7.10 we introduce a new agent action POLICY_CHANGE per policy + * this function ensure that action exist for each policy + * + * @param soClient + */ +export async function ensureAgentActionPolicyChangeExists(soClient: SavedObjectsClientContract) { + // If Agents are not setup skip + if (!(await isAgentsSetup(soClient))) { + return; + } + + const { items: agentPolicies } = await agentPolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + }); + + await Promise.all( + agentPolicies.map(async (agentPolicy) => { + const policyChangeActionExist = !!(await getLatestConfigChangeAction( + soClient, + agentPolicy.id + )); + + if (!policyChangeActionExist) { + return agentPolicyService.createFleetPolicyChangeAction(soClient, agentPolicy.id); + } + }) + ); +} diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index f780bd95faedce..0e3b652422faae 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -65,8 +65,8 @@ class OutputService { return outputs.saved_objects[0].id; } - public async getAdminUser(soClient: SavedObjectsClientContract) { - if (cachedAdminUser) { + public async getAdminUser(soClient: SavedObjectsClientContract, useCache = true) { + if (useCache && cachedAdminUser) { return cachedAdminUser; } diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index c7ecf843d6a51e..7f379d3ea4f13d 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -29,6 +29,7 @@ import { generateEnrollmentAPIKey } from './api_keys'; import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; +import { ensureAgentActionPolicyChangeExists } from './agents'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -80,6 +81,7 @@ async function createSetupSideEffects( ) { throw new Error('Policy not found'); } + for (const installedPackage of installedPackages) { const packageShouldBeInstalled = DEFAULT_AGENT_POLICIES_PACKAGES.some( (packageName) => installedPackage.name === packageName @@ -105,6 +107,8 @@ async function createSetupSideEffects( } } + await ensureAgentActionPolicyChangeExists(soClient); + return { isIntialized: true }; } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx index f01228a26297b2..841654fcc429e6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx @@ -6,7 +6,7 @@ import React, { FunctionComponent, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; - +import { get } from 'lodash'; import { FIELD_TYPES, UseField, @@ -44,8 +44,8 @@ export const Json: FunctionComponent = () => { const form = useFormContext(); const [isAddToPathDisabled, setIsAddToPathDisabled] = useState(false); useEffect(() => { - const subscription = form.subscribe(({ data: { raw: rawData } }) => { - const hasTargetField = !!rawData[TARGET_FIELD_PATH]; + const subscription = form.subscribe(({ data: { internal } }) => { + const hasTargetField = !!get(internal, TARGET_FIELD_PATH); if (hasTargetField && !isAddToPathDisabled) { setIsAddToPathDisabled(true); form.getFields()[ADD_TO_ROOT_FIELD_PATH].setValue(false); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx index aa9e2879aaddfa..f1d7f7a4868948 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx @@ -21,6 +21,7 @@ import { Document } from '../../types'; import { Tabs, TestPipelineFlyoutTab, OutputTab, DocumentsTab } from './test_pipeline_tabs'; import { TestPipelineFlyoutForm } from './test_pipeline_flyout.container'; + export interface Props { onClose: () => void; handleTestPipeline: ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx index cd82e0f4ff5ca1..6888f947b86067 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx @@ -21,7 +21,6 @@ import { ValidationFuncArg, FormHook, Form, - useFormData, } from '../../../../../../../shared_imports'; import { Document } from '../../../../types'; import { AddDocumentsAccordion } from './add_documents_accordion'; @@ -149,16 +148,15 @@ export const DocumentsTab: FunctionComponent = ({ resetTestOutput, }) => { const { services } = useKibana(); - - const [, formatData] = useFormData({ form }); + const { getFormData, reset } = form; const onAddDocumentHandler = useCallback( (document) => { - const { documents: existingDocuments = [] } = formatData(); + const { documents: existingDocuments = [] } = getFormData(); - form.reset({ defaultValue: { documents: [...existingDocuments, document] } }); + reset({ defaultValue: { documents: [...existingDocuments, document] } }); }, - [form, formatData] + [reset, getFormData] ); const [showResetModal, setShowResetModal] = useState(false); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 25813aa9656976..edee2bf8e6b1dc 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -696,6 +696,9 @@ describe('Lens App', () => { undefined ); expect(props.redirectTo).toHaveBeenCalledWith('aaa'); + expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( + "Saved 'hello there'" + ); }); it('adds to the recently accessed list on save', async () => { @@ -730,6 +733,9 @@ describe('Lens App', () => { component.setProps({ initialInput: { savedObjectId: 'aaa' } }); }); expect(services.attributeService.wrapAttributes).toHaveBeenCalledTimes(1); + expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( + "Saved 'hello there'" + ); }); it('saves existing docs', async () => { @@ -751,6 +757,9 @@ describe('Lens App', () => { component.setProps({ initialInput: { savedObjectId: defaultSavedObjectId } }); }); expect(services.attributeService.unwrapAttributes).toHaveBeenCalledTimes(1); + expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith( + "Saved 'hello there'" + ); }); it('handles save failure by showing a warning, but still allows another save', async () => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 3407ea5de49c46..2d35c203661d05 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -411,6 +411,15 @@ export function App({ return; } + notifications.toasts.addSuccess( + i18n.translate('xpack.lens.app.saveVisualization.successNotificationText', { + defaultMessage: `Saved '{visTitle}'`, + values: { + visTitle: docToSave.title, + }, + }) + ); + if ( attributeService.inputIsRefType(newInput) && newInput.savedObjectId !== originalSavedObjectId diff --git a/x-pack/plugins/lens/server/usage/task.ts b/x-pack/plugins/lens/server/usage/task.ts index 9fee72b59b44cd..83cdbd62f3484b 100644 --- a/x-pack/plugins/lens/server/usage/task.ts +++ b/x-pack/plugins/lens/server/usage/task.ts @@ -48,7 +48,6 @@ function registerLensTelemetryTask( taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Lens usage fetch task', - type: TELEMETRY_TASK_TYPE, timeout: '1m', createTaskRunner: telemetryTaskRunner(logger, core, config), }, diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 429f6c7af6e274..27676422543220 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -120,7 +120,9 @@ exports[`UploadLicense should display a modal when license requires acknowledgem "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -171,7 +173,9 @@ exports[`UploadLicense should display a modal when license requires acknowledgem "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -213,7 +217,9 @@ exports[`UploadLicense should display a modal when license requires acknowledgem "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1035,22 +1041,30 @@ exports[`UploadLicense should display an error when ES says license is expired 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1113,22 +1127,30 @@ exports[`UploadLicense should display an error when ES says license is expired 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1182,22 +1204,30 @@ exports[`UploadLicense should display an error when ES says license is expired 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1723,22 +1753,30 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1801,22 +1839,30 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -1870,22 +1916,30 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -2411,22 +2465,30 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -2489,22 +2551,30 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -2558,22 +2628,30 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -3099,22 +3177,30 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -3177,22 +3263,30 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], @@ -3246,22 +3340,30 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` "calls": Array [ Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], Array [ Object { + "hash": "", "pathname": "/home", + "search": "", }, ], ], diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts index 50196a1a0bcc70..2fafe9de59fdb1 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.ts @@ -23,7 +23,7 @@ export type ReturnExceptionListAndItems = [ * Hook for using to get an ExceptionList and it's ExceptionListItems * * @param http Kibana http service - * @param lists array of ExceptionIdentifiers for all lists to fetch + * @param lists array of ExceptionListIdentifiers for all lists to fetch * @param onError error callback * @param onSuccess callback when all lists fetched successfully * @param filterOptions optional - filter by fields or tags diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index ac21288848154c..64e4efb5daad27 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -50,7 +50,7 @@ export interface UseExceptionListSuccess { export interface UseExceptionListProps { http: HttpStart; - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; onError?: (arg: string[]) => void; filterOptions: FilterExceptionsOptions[]; pagination?: Pagination; @@ -60,7 +60,7 @@ export interface UseExceptionListProps { onSuccess?: (arg: UseExceptionListSuccess) => void; } -export interface ExceptionIdentifiers { +export interface ExceptionListIdentifiers { id: string; listId: string; namespaceType: NamespaceType; @@ -91,7 +91,7 @@ export interface ApiCallMemoProps { } export interface ApiCallFindListsItemsMemoProps { - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; filterOptions: FilterExceptionsOptions[]; pagination: Partial; showDetectionsListsOnly: boolean; diff --git a/x-pack/plugins/lists/public/exceptions/utils.ts b/x-pack/plugins/lists/public/exceptions/utils.ts index 2acb690d3822c2..3a5984956b0d0a 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.ts @@ -6,14 +6,14 @@ import { NamespaceType } from '../../common/schemas'; -import { ExceptionIdentifiers } from './types'; +import { ExceptionListIdentifiers } from './types'; export const getIdsAndNamespaces = ({ lists, showDetection, showEndpoint, }: { - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; showDetection: boolean; showEndpoint: boolean; }): { ids: string[]; namespaces: NamespaceType[] } => diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index 16026a436f1549..b8293971d14dff 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -30,7 +30,7 @@ export { } from './exceptions/api'; export { ExceptionList, - ExceptionIdentifiers, + ExceptionListIdentifiers, Pagination, UseExceptionListSuccess, } from './exceptions/types'; diff --git a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.tsx b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.tsx index d66a0d1b7d0dae..3b0891a4fd44df 100644 --- a/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routing/routes/list/maps_list_view.tsx @@ -42,6 +42,7 @@ import { getNavigateToApp, } from '../../../kibana_services'; import { getMapsSavedObjectLoader } from '../../bootstrap/services/gis_map_saved_object_loader'; +import { getAppTitle } from '../../../../common/i18n_getters'; export const EMPTY_FILTER = ''; @@ -101,7 +102,8 @@ export class MapsListView extends React.Component { async initMapList() { this.fetchItems(); addHelpMenuToAppChrome(); - getCoreChrome().docTitle.change('Maps'); + getCoreChrome().docTitle.change(getAppTitle()); + getCoreChrome().setBreadcrumbs([{ text: getAppTitle() }]); } _find = (search: string) => getMapsSavedObjectLoader().find(search, this.state.listingLimit); diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx index 149c04b414c180..1f74b0d6d14498 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/get_breadcrumbs.tsx @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { getNavigateToApp } from '../../../kibana_services'; import { goToSpecifiedPath } from '../../maps_router'; +import { getAppTitle } from '../../../../common/i18n_getters'; export const unsavedChangesWarning = i18n.translate( 'xpack.maps.breadCrumbs.unsavedChangesWarning', @@ -37,9 +38,7 @@ export function getBreadcrumbs({ } breadcrumbs.push({ - text: i18n.translate('xpack.maps.mapController.mapsBreadcrumbLabel', { - defaultMessage: 'Maps', - }), + text: getAppTitle(), onClick: () => { if (getHasUnsavedChanges()) { const navigateAway = window.confirm(unsavedChangesWarning); diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 46e39fcdac27a2..00950e96047a0e 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -171,8 +171,6 @@ export class MapsPlugin implements Plugin { }), order: 400, category: DEFAULT_APP_CATEGORIES.kibana, - icon: APP_ICON, - navLinkId: APP_ID, app: [APP_ID, 'kibana'], catalogue: [APP_ID], privileges: { diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 5d0ecf96fb6b5f..cbf2acd1524766 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -19,7 +19,9 @@ export type DataFrameAnalyticsId = string; export interface OutlierAnalysis { [key: string]: {}; - outlier_detection: {}; + outlier_detection: { + compute_feature_influence?: boolean; + }; } interface Regression { diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts index d231ed43443892..94797efdfcfadf 100644 --- a/x-pack/plugins/ml/common/util/analytics_utils.ts +++ b/x-pack/plugins/ml/common/util/analytics_utils.ts @@ -13,16 +13,19 @@ import { import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => { + if (typeof arg !== 'object' || arg === null) return false; const keys = Object.keys(arg); return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; }; export const isRegressionAnalysis = (arg: any): arg is RegressionAnalysis => { + if (typeof arg !== 'object' || arg === null) return false; const keys = Object.keys(arg); return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.REGRESSION; }; export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { + if (typeof arg !== 'object' || arg === null) return false; const keys = Object.keys(arg); return keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; }; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index f88694a1952b2c..642d0ae564b859 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -33,7 +33,6 @@ import { import { FEATURE_IMPORTANCE, - FEATURE_INFLUENCE, OUTLIER_SCORE, TOP_CLASSES, } from '../../data_frame_analytics/common/constants'; @@ -112,10 +111,7 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results schema = NON_AGGREGATABLE; } - if ( - field === `${resultsField}.${OUTLIER_SCORE}` || - field.includes(`${resultsField}.${FEATURE_INFLUENCE}`) - ) { + if (field === `${resultsField}.${OUTLIER_SCORE}`) { schema = 'numeric'; } @@ -203,11 +199,6 @@ export const useRenderCellValue = ( } function getCellValue(cId: string) { - if (cId.includes(`.${FEATURE_INFLUENCE}.`) && resultsField !== undefined) { - const results = getNestedProperty(tableItems[adjustedRowIndex], resultsField, null); - return results[cId.replace(`${resultsField}.`, '')]; - } - if (tableItems.hasOwnProperty(adjustedRowIndex)) { const item = tableItems[adjustedRowIndex]; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index e4581f0a87bddc..c606cbd1cc11ac 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -17,7 +17,7 @@ import { import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; import { newJobCapsService } from '../../services/new_job_capabilities_service'; -import { FEATURE_IMPORTANCE, FEATURE_INFLUENCE, OUTLIER_SCORE, TOP_CLASSES } from './constants'; +import { FEATURE_IMPORTANCE, OUTLIER_SCORE, TOP_CLASSES } from './constants'; import { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; export type EsId = string; @@ -179,7 +179,6 @@ export const getDefaultFieldsFromJobCaps = ( const resultsField = jobConfig.dest.results_field; const featureImportanceFields = []; - const featureInfluenceFields = []; const topClassesFields = []; const allFields: any = []; let type: ES_FIELD_TYPES | undefined; @@ -193,16 +192,6 @@ export const getDefaultFieldsFromJobCaps = ( name: `${resultsField}.${OUTLIER_SCORE}`, type: KBN_FIELD_TYPES.NUMBER, }); - - featureInfluenceFields.push( - ...fields - .filter((d) => !jobConfig.analyzed_fields.excludes.includes(d.id)) - .map((d) => ({ - id: `${resultsField}.${FEATURE_INFLUENCE}.${d.id}`, - name: `${resultsField}.${FEATURE_INFLUENCE}.${d.name}`, - type: KBN_FIELD_TYPES.NUMBER, - })) - ); } } @@ -247,12 +236,7 @@ export const getDefaultFieldsFromJobCaps = ( } } - allFields.push( - ...fields, - ...featureImportanceFields, - ...featureInfluenceFields, - ...topClassesFields - ); + allFields.push(...fields, ...featureImportanceFields, ...topClassesFields); allFields.sort(({ name: a }: { name: string }, { name: b }: { name: string }) => sortExplorationResultsFields(a, b, jobConfig) ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts index 667dea27de96e3..8e50aab0914db4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_index_data.ts @@ -19,7 +19,8 @@ import { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_an export const getIndexData = async ( jobConfig: DataFrameAnalyticsConfig | undefined, dataGrid: UseDataGridReturnType, - searchQuery: SavedSearchQuery + searchQuery: SavedSearchQuery, + options: { didCancel: boolean } ) => { if (jobConfig !== undefined) { const { @@ -52,7 +53,7 @@ export const getIndexData = async ( index: jobConfig.dest.index, body: { fields: ['*'], - _source: jobConfig.dest.results_field, + _source: [], query: searchQuery, from: pageIndex * pageSize, size: pageSize, @@ -60,14 +61,11 @@ export const getIndexData = async ( }, }); - setRowCount(resp.hits.total.value); - const docs = resp.hits.hits.map((d) => ({ - ...getProcessedFields(d.fields), - [jobConfig.dest.results_field]: d._source[jobConfig.dest.results_field], - })); - - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); + if (!options.didCancel) { + setRowCount(resp.hits.total.value); + setTableItems(resp.hits.hits.map((d) => getProcessedFields(d.fields))); + setStatus(INDEX_STATUS.LOADED); + } } catch (e) { setErrorMessage(extractErrorMessage(e)); setStatus(INDEX_STATUS.ERROR); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts index 7d2ca86a38083d..81c2e246120c01 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts @@ -6,6 +6,8 @@ import { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + import { IndexPattern } from '../../../../../../../src/plugins/data/public'; import { extractErrorMessage } from '../../../../common/util/errors'; @@ -32,6 +34,9 @@ export const useResultsViewConfig = (jobId: string) => { const trainedModelsApiService = useTrainedModelsApiService(); const [indexPattern, setIndexPattern] = useState(undefined); + const [indexPatternErrorMessage, setIndexPatternErrorMessage] = useState( + undefined + ); const [isInitialized, setIsInitialized] = useState(false); const [needsDestIndexPattern, setNeedsDestIndexPattern] = useState(false); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); @@ -105,7 +110,11 @@ export const useResultsViewConfig = (jobId: string) => { setNeedsDestIndexPattern(true); const sourceIndex = jobConfigUpdate.source.index[0]; const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; - indexP = await mlContext.indexPatterns.get(sourceIndexPatternId); + try { + indexP = await mlContext.indexPatterns.get(sourceIndexPatternId); + } catch (e) { + indexP = undefined; + } } if (indexP !== undefined) { @@ -114,6 +123,16 @@ export const useResultsViewConfig = (jobId: string) => { setIndexPattern(indexP); setIsInitialized(true); setIsLoadingJobConfig(false); + } else { + setIndexPatternErrorMessage( + i18n.translate( + 'xpack.ml.dataframe.analytics.results.indexPatternsMissingErrorMessage', + { + defaultMessage: + 'To view this page, a Kibana index pattern is necessary for either the destination or source index of this analytics job.', + } + ) + ); } } catch (e) { setJobCapsServiceErrorMessage(extractErrorMessage(e)); @@ -129,6 +148,7 @@ export const useResultsViewConfig = (jobId: string) => { return { indexPattern, + indexPatternErrorMessage, isInitialized, isLoadingJobConfig, jobCapsServiceErrorMessage, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index de4d1a97f248fd..cdecead21d4dee 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -6,7 +6,7 @@ import React, { FC, useEffect, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useUrlState } from '../../../../../util/url_state'; @@ -70,6 +70,7 @@ export const ExplorationPageWrapper: FC = ({ }) => { const { indexPattern, + indexPatternErrorMessage, isInitialized, isLoadingJobConfig, jobCapsServiceErrorMessage, @@ -99,6 +100,22 @@ export const ExplorationPageWrapper: FC = ({ } }, [jobConfig?.dest.results_field]); + if (indexPatternErrorMessage !== undefined) { + return ( + + +

{indexPatternErrorMessage}

+
+
+ ); + } + if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( { - getIndexData(jobConfig, dataGrid, searchQuery); + const options = { didCancel: false }; + getIndexData(jobConfig, dataGrid, searchQuery, options); + return () => { + options.didCancel = true; + }; // custom comparison }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/common.ts index d1889a8acb990d..1ce3b3528e44b8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/common.ts @@ -19,11 +19,8 @@ export const getFeatureCount = (resultsField: string, tableItems: DataGridItem[] const fullItem = tableItems[0]; - if ( - fullItem[resultsField] !== undefined && - Array.isArray(fullItem[resultsField][FEATURE_INFLUENCE]) - ) { - return fullItem[resultsField][FEATURE_INFLUENCE].length; + if (Array.isArray(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.feature_name`])) { + return fullItem[`${resultsField}.${FEATURE_INFLUENCE}.feature_name`].length; } return 0; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 8fc2486599755f..9e30ed3cdfe95f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -6,7 +6,9 @@ import React, { useState, FC } from 'react'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiCallOut, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; import { useColorRange, @@ -15,7 +17,8 @@ import { } from '../../../../../components/color_range_legend'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { defaultSearchQuery, useResultsViewConfig } from '../../../../common'; +import { defaultSearchQuery, isOutlierAnalysis, useResultsViewConfig } from '../../../../common'; +import { FEATURE_INFLUENCE } from '../../../../common/constants'; import { ExpandableSectionAnalytics, ExpandableSectionResults } from '../expandable_section'; import { ExplorationQueryBar } from '../exploration_query_bar'; @@ -30,17 +33,54 @@ interface ExplorationProps { } export const OutlierExploration: FC = React.memo(({ jobId }) => { - const { indexPattern, jobConfig, needsDestIndexPattern } = useResultsViewConfig(jobId); + const { + indexPattern, + indexPatternErrorMessage, + jobConfig, + needsDestIndexPattern, + } = useResultsViewConfig(jobId); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery); const { columnsWithCharts, tableItems } = outlierData; - const colorRange = useColorRange( - COLOR_RANGE.BLUE, - COLOR_RANGE_SCALE.INFLUENCER, - jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1 - ); + const featureCount = getFeatureCount(jobConfig?.dest?.results_field || '', tableItems); + const colorRange = useColorRange(COLOR_RANGE.BLUE, COLOR_RANGE_SCALE.INFLUENCER, featureCount); + + // Show the color range only if feature influence is enabled and there's more than 0 features. + const showColorRange = + featureCount > 0 && + isOutlierAnalysis(jobConfig?.analysis) && + jobConfig?.analysis.outlier_detection.compute_feature_influence === true; + + const resultsField = jobConfig?.dest.results_field ?? ''; + + // Identify if the results index has a legacy feature influence format. + // If feature influence was enabled for the legacy job we'll show a callout + // with some additional information for a workaround. + const showLegacyFeatureInfluenceFormatCallout = + !needsDestIndexPattern && + isOutlierAnalysis(jobConfig?.analysis) && + jobConfig?.analysis.outlier_detection.compute_feature_influence === true && + columnsWithCharts.findIndex( + (d) => d.id === `${resultsField}.${FEATURE_INFLUENCE}.feature_name` + ) === -1; + + if (indexPatternErrorMessage !== undefined) { + return ( + + +

{indexPatternErrorMessage}

+
+
+ ); + } return ( <> @@ -58,8 +98,26 @@ export const OutlierExploration: FC = React.memo(({ jobId }) = )} {typeof jobConfig?.id === 'string' && } + {showLegacyFeatureInfluenceFormatCallout && ( + <> + + + + )} { - getIndexData(jobConfig, dataGrid, searchQuery); + const options = { didCancel: false }; + getIndexData(jobConfig, dataGrid, searchQuery, options); + return () => { + options.didCancel = true; + }; // custom comparison }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); @@ -151,19 +153,17 @@ export const useOutlierData = ( const split = columnId.split('.'); let backgroundColor; + const featureNames = fullItem[`${resultsField}.${FEATURE_INFLUENCE}.feature_name`]; + // column with feature values get color coded by its corresponding influencer value - if ( - fullItem[resultsField] !== undefined && - fullItem[resultsField][FEATURE_INFLUENCE] !== undefined && - fullItem[resultsField][FEATURE_INFLUENCE].find( - (d: FeatureInfluence) => d.feature_name === columnId - ) !== undefined - ) { - backgroundColor = colorRange( - fullItem[resultsField][FEATURE_INFLUENCE].find( - (d: FeatureInfluence) => d.feature_name === columnId - ).influence - ); + if (Array.isArray(featureNames)) { + const featureIndex = featureNames.indexOf(columnId); + + if (featureIndex > -1) { + backgroundColor = colorRange( + fullItem[`${resultsField}.${FEATURE_INFLUENCE}.influence`][featureIndex] + ); + } } // column with influencer values get color coded by its own value 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 7d715839772046..7d2c7d60e3ee6e 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 @@ -58,7 +58,12 @@ const emptyOption: EuiComboBoxOptionOption = { label: '', }; -const excludeFrequentOptions: EuiComboBoxOptionOption[] = [{ label: 'all' }, { label: 'none' }]; +const excludeFrequentOptions: EuiComboBoxOptionOption[] = [ + { label: 'all' }, + { label: 'none' }, + { label: 'by' }, + { label: 'over' }, +]; export const AdvancedDetectorModal: FC = ({ payload, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx index 54fd8ba0359581..280ac85a5a2bc2 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx @@ -142,7 +142,7 @@ export const ExcludeFrequentDescription: FC = memo(({ children }) => { description={ } > diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index d0c837b7a1caf7..1592973022dbd1 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -17,7 +17,7 @@ import { } from 'kibana/server'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { PluginsSetup, RouteInitialization } from './types'; -import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; +import { PLUGIN_ID } from '../common/constants/app'; import { MlCapabilities } from '../common/types/capabilities'; import { initMlTelemetry } from './lib/telemetry'; @@ -73,10 +73,8 @@ export class MlServerPlugin implements Plugin { expect(queryAllByText('View in app')).toEqual([]); expect(getByText('elastic-co-frontend')).toBeInTheDocument(); }); + it('shows empty state', () => { + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: undefined, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); + const { getByText, queryAllByText, getAllByText } = render( + + ); + + expect(getByText('User Experience')).toBeInTheDocument(); + expect(getAllByText('No data is available.')).toHaveLength(3); + expect(queryAllByText('View in app')).toEqual([]); + expect(getByText('elastic-co-frontend')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx index e5b8d2c243617e..0d0a388855ff24 100644 --- a/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/core_vital_item.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiIconTip, euiPaletteForStatus, EuiSpacer, EuiStat } from '@elastic/eui'; +import { + EuiCard, + EuiFlexGroup, + EuiIconTip, + euiPaletteForStatus, + EuiSpacer, + EuiStat, +} from '@elastic/eui'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { PaletteLegends } from './palette_legends'; @@ -14,6 +21,7 @@ import { CV_GOOD_LABEL, LESS_LABEL, MORE_LABEL, + NO_DATA, CV_POOR_LABEL, IS_LABEL, TAKES_LABEL, @@ -26,7 +34,7 @@ export interface Thresholds { interface Props { title: string; - value: string; + value?: string; ranks?: number[]; loading: boolean; thresholds: Thresholds; @@ -80,6 +88,9 @@ export function CoreVitalItem({ const biggestValIndex = ranks.indexOf(Math.max(...ranks)); + if (value === undefined && ranks[0] === 100 && !loading) { + return ; + } return ( <> )} - {hasData?.ux && ( + {(hasData.ux as UXHasDataResponse).hasData && ( ; @@ -88,6 +88,8 @@ export function OverviewPage({ routeParams }: Props) { const appEmptySections = getEmptySections({ core }).filter(({ id }) => { if (id === 'alert') { return alertStatus !== FETCH_STATUS.FAILURE && !alerts.length; + } else if (id === 'ux') { + return !(hasData[id] as UXHasDataResponse).hasData; } return !hasData[id]; }); diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts index c501ad82954a3d..0663c239c61ba6 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -19,8 +19,6 @@ export const createFeature = ( ) => { const { excludeFromBaseAll, excludeFromBaseRead, privileges, category, ...rest } = config; return new KibanaFeature({ - icon: 'discoverApp', - navLinkId: 'discover', app: [], category: category ?? { id: 'foo', label: 'foo' }, catalogue: [], diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 7dff2912e6aa3d..e6daa15fe7e6e0 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -30,7 +30,6 @@ const buildFeatures = () => { new KibanaFeature({ id: 'feature1', name: 'Feature 1', - icon: 'addDataApp', app: ['feature1App'], category: { id: 'foo', label: 'foo' }, privileges: { @@ -55,7 +54,6 @@ const buildFeatures = () => { new KibanaFeature({ id: 'feature2', name: 'Feature 2', - icon: 'addDataApp', app: ['feature2App'], category: { id: 'foo', label: 'foo' }, privileges: { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index 77b6da2a004871..6601c6ae1f8d55 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -19,7 +19,6 @@ const buildProps = (customProps: any = {}) => { name: 'Feature 1', app: ['app'], category: { id: 'foo', label: 'foo' }, - icon: 'spacesApp', privileges: { all: { app: ['app'], diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index ea24560c8ddc9b..ac97822a403af0 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -81,7 +81,6 @@ describe('usingPrivileges', () => { name: 'Foo KibanaFeature', app: ['fooApp', 'foo'], category: { id: 'foo', label: 'foo' }, - navLinkId: 'foo', privileges: null, }), ], @@ -170,7 +169,6 @@ describe('usingPrivileges', () => { name: 'Foo KibanaFeature', app: ['foo'], category: { id: 'foo', label: 'foo' }, - navLinkId: 'foo', privileges: null, }), ], @@ -322,7 +320,6 @@ describe('usingPrivileges', () => { new KibanaFeature({ id: 'fooFeature', name: 'Foo KibanaFeature', - navLinkId: 'foo', app: [], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -330,7 +327,6 @@ describe('usingPrivileges', () => { new KibanaFeature({ id: 'barFeature', name: 'Bar KibanaFeature', - navLinkId: 'bar', app: ['bar'], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -471,7 +467,6 @@ describe('usingPrivileges', () => { new KibanaFeature({ id: 'fooFeature', name: 'Foo KibanaFeature', - navLinkId: 'foo', app: [], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -479,7 +474,6 @@ describe('usingPrivileges', () => { new KibanaFeature({ id: 'barFeature', name: 'Bar KibanaFeature', - navLinkId: 'bar', app: [], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -559,7 +553,6 @@ describe('all', () => { name: 'Foo KibanaFeature', app: ['foo'], category: { id: 'foo', label: 'foo' }, - navLinkId: 'foo', privileges: null, }), ], diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index 89cc9065655cd6..ec975ca5f973f2 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -28,7 +28,6 @@ export function disableUICapabilitiesFactory( ) { // nav links are sourced from the apps property. // The Kibana Platform associates nav links to the app which registers it, in a 1:1 relationship. - // This behavior is replacing the `navLinkId` property. const featureNavLinkIds = features .flatMap((feature) => feature.app) .filter((navLinkId) => navLinkId != null); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 2b1268b11a0ffa..c7b015b001ccf0 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -18,8 +18,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo-feature', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], category: { id: 'foo', label: 'foo' }, catalogue: ['catalogue-1', 'catalogue-2'], @@ -65,7 +63,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { @@ -169,7 +166,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -211,8 +207,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: [], category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], @@ -336,8 +330,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: [], category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], @@ -421,8 +413,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: [], category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], @@ -480,8 +470,6 @@ describe('features', () => { id: 'foo', name: 'Foo KibanaFeature', excludeFromBasePrivileges: true, - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: [], category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], @@ -546,8 +534,6 @@ describe('features', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: [], category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], @@ -617,8 +603,6 @@ describe('reserved', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], category: { id: 'foo', label: 'foo' }, catalogue: ['catalogue-1', 'catalogue-2'], @@ -661,7 +645,6 @@ describe('reserved', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: null, @@ -728,7 +711,6 @@ describe('reserved', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { @@ -770,7 +752,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { @@ -899,7 +880,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { @@ -1106,7 +1086,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, excludeFromBasePrivileges: true, @@ -1251,7 +1230,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { @@ -1419,7 +1397,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, excludeFromBasePrivileges: true, @@ -1551,7 +1528,6 @@ describe('subFeatures', () => { new KibanaFeature({ id: 'foo', name: 'Foo KibanaFeature', - icon: 'arrowDown', app: [], category: { id: 'foo', label: 'foo' }, privileges: { diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts index 0328455fc83796..155cc0bdd58ff7 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.test.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -50,7 +50,6 @@ describe('SessionManagementService', () => { expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { title: 'Cleanup expired or invalid user sessions', - type: SESSION_INDEX_CLEANUP_TASK_NAME, createTaskRunner: expect.any(Function), }, }); diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts index 60c0f7c23e959e..fc2e85d683d586 100644 --- a/x-pack/plugins/security/server/session_management/session_management_service.ts +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -78,7 +78,6 @@ export class SessionManagementService { taskManager.registerTaskDefinitions({ [SESSION_INDEX_CLEANUP_TASK_NAME]: { title: 'Cleanup expired or invalid user sessions', - type: SESSION_INDEX_CLEANUP_TASK_NAME, createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), }, }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index d6352d2e6aa151..8ff75b25388b0f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -1112,6 +1112,7 @@ describe('get_filter', () => { '@timestamp': { gte: 'now-5m', lte: 'now', + format: 'strict_date_optional_time', }, }, }, @@ -1148,6 +1149,7 @@ describe('get_filter', () => { 'event.ingested': { gte: 'now-5m', lte: 'now', + format: 'strict_date_optional_time', }, }, }, @@ -1183,6 +1185,7 @@ describe('get_filter', () => { '@timestamp': { gte: 'now-5m', lte: 'now', + format: 'strict_date_optional_time', }, }, }, diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 278ce1d39ae9fb..73638fc48f3813 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -117,6 +117,7 @@ export const buildEqlSearchRequest = ( [timestamp]: { gte: from, lte: to, + format: 'strict_date_optional_time', }, }, }, diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index 37b73088561960..3250e048edad21 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -24,6 +24,12 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + popup: { + malware: { + message: '', + enabled: true, + }, + }, logging: { file: 'info', }, @@ -37,6 +43,12 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + popup: { + malware: { + message: '', + enabled: true, + }, + }, logging: { file: 'info', }, diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts new file mode 100644 index 00000000000000..33cf497e593111 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { PackagePolicy } from '../../../../../ingest_manager/common'; +import { migratePackagePolicyToV7110 } from './to_v7_11.0'; + +describe('7.11.0 Endpoint Package Policy migration', () => { + const migration = migratePackagePolicyToV7110; + it('adds malware notification checkbox and optional message', () => { + const doc: SavedObjectUnsanitizedDoc = { + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: {}, + mac: {}, + }, + }, + }, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + }, + }, + }, + }, + ], + }, + type: ' nested', + }); + }); + + it('does not modify non-endpoint package policies', () => { + const doc: SavedObjectUnsanitizedDoc = { + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts new file mode 100644 index 00000000000000..8c2dabae21bbde --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { cloneDeep } from 'lodash'; +import { PackagePolicy } from '../../../../../ingest_manager/common'; + +export const migratePackagePolicyToV7110: SavedObjectMigrationFn = ( + packagePolicyDoc +) => { + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc = cloneDeep( + packagePolicyDoc + ); + if (packagePolicyDoc.attributes.package?.name === 'endpoint') { + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + const popup = { + malware: { + message: '', + enabled: false, + }, + }; + if (input && input.config) { + input.config.policy.value.windows.popup = popup; + input.config.policy.value.mac.popup = popup; + } + } + + return updatedPackagePolicyDoc; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index f2033e064ef721..882b3e5182bf39 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -873,6 +873,12 @@ export interface PolicyConfig { logging: { file: string; }; + popup: { + malware: { + message: string; + enabled: boolean; + }; + }; }; mac: { events: { @@ -881,6 +887,12 @@ export interface PolicyConfig { network: boolean; }; malware: MalwareFields; + popup: { + malware: { + message: string; + enabled: boolean; + }; + }; logging: { file: string; }; @@ -904,11 +916,11 @@ export interface UIPolicyConfig { /** * Windows-specific policy configuration that is supported via the UI */ - windows: Pick; + windows: Pick; /** * Mac-specific policy configuration that is supported via the UI */ - mac: Pick; + mac: Pick; /** * Linux-specific policy configuration that is supported via the UI */ diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index 6269c3cee999c9..bee2e54d0e3eab 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -16,3 +16,4 @@ export { exactCheck } from './exact_check'; export { getPaths, foldLeftRight } from './test_utils'; export { validate, validateEither } from './validate'; export { formatErrors } from './format_errors'; +export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11.0'; diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index 41665cf6d20a44..491f4f8952fd98 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -73,6 +73,7 @@ import { import { changeToThreeHundredRowsPerPage, deleteFirstRule, + deleteRule, deleteSelectedRules, editFirstRule, filterByCustomRules, @@ -91,12 +92,12 @@ import { goToAboutStepTab, goToActionsStepTab, goToScheduleStepTab, + waitForAlertsToPopulate, waitForTheRuleToBeExecuted, } from '../tasks/create_new_rule'; import { saveEditedRule, waitForKibana } from '../tasks/edit_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { refreshPage } from '../tasks/security_header'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -119,6 +120,7 @@ describe('Custom detection rules creation', () => { }); after(() => { + deleteRule(); esArchiverUnload('timeline'); }); @@ -197,14 +199,10 @@ describe('Custom detection rules creation', () => { ); }); - refreshPage(); waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) - .invoke('text') - .then((numberOfAlertsText) => { - cy.wrap(parseInt(numberOfAlertsText, 10)).should('be.above', 0); - }); + cy.get(NUMBER_OF_ALERTS).invoke('text').then(parseFloat).should('be.above', 0); cy.get(ALERT_RULE_NAME).first().should('have.text', newRule.name); cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts index 5502f35d6f0f84..bee4713ca7cda1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_eql.spec.ts @@ -55,6 +55,7 @@ import { } from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, + deleteRule, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -67,11 +68,11 @@ import { fillDefineEqlRuleAndContinue, fillScheduleRuleAndContinue, selectEqlRuleType, + waitForAlertsToPopulate, waitForTheRuleToBeExecuted, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { refreshPage } from '../tasks/security_header'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -87,13 +88,13 @@ const expectedNumberOfRules = 1; const expectedNumberOfAlerts = 7; const expectedNumberOfSequenceAlerts = 1; -// Failing: See https://github.com/elastic/kibana/issues/79522 -describe.skip('Detection rules, EQL', () => { +describe('Detection rules, EQL', () => { beforeEach(() => { esArchiverLoad('timeline'); }); afterEach(() => { + deleteRule(); esArchiverUnload('timeline'); }); @@ -160,14 +161,10 @@ describe.skip('Detection rules, EQL', () => { ); }); - refreshPage(); waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) - .invoke('text') - .then((numberOfAlertsText) => { - cy.wrap(parseInt(numberOfAlertsText, 10)).should('eql', expectedNumberOfAlerts); - }); + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', eqlRule.name); cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); @@ -199,14 +196,10 @@ describe.skip('Detection rules, EQL', () => { filterByCustomRules(); goToRuleDetails(); - refreshPage(); waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) - .invoke('text') - .then((numberOfAlertsText) => { - cy.wrap(parseInt(numberOfAlertsText, 10)).should('eql', expectedNumberOfSequenceAlerts); - }); + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfSequenceAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', eqlSequenceRule.name); cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); cy.get(ALERT_RULE_METHOD).first().should('have.text', 'eql'); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts index 0f34e7d71e5faa..153c55fae59fe6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_ml.spec.ts @@ -44,6 +44,7 @@ import { } from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, + deleteRule, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -78,6 +79,7 @@ describe('Detection rules, machine learning', () => { }); after(() => { + deleteRule(); esArchiverUnload('prebuilt_rules_loaded'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts index edf7305f6916e6..e31fe2e9a39113 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_override.spec.ts @@ -72,11 +72,11 @@ import { fillAboutRuleWithOverrideAndContinue, fillDefineCustomRuleWithImportedQueryAndContinue, fillScheduleRuleAndContinue, + waitForAlertsToPopulate, waitForTheRuleToBeExecuted, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { refreshPage } from '../tasks/security_header'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -179,14 +179,10 @@ describe('Detection rules, override', () => { ); }); - refreshPage(); waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) - .invoke('text') - .then((numberOfAlertsText) => { - cy.wrap(parseInt(numberOfAlertsText, 10)).should('be.above', 0); - }); + cy.get(NUMBER_OF_ALERTS).invoke('text').then(parseFloat).should('be.above', 0); cy.get(ALERT_RULE_NAME).first().should('have.text', 'auditbeat'); cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); cy.get(ALERT_RULE_METHOD).first().should('have.text', 'query'); diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts index 5095e856e3f65d..a6f974256f3e49 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_threshold.spec.ts @@ -56,6 +56,7 @@ import { } from '../tasks/alerts'; import { changeToThreeHundredRowsPerPage, + deleteRule, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -68,11 +69,11 @@ import { fillDefineThresholdRuleAndContinue, fillScheduleRuleAndContinue, selectThresholdRuleType, + waitForAlertsToPopulate, waitForTheRuleToBeExecuted, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; -import { refreshPage } from '../tasks/security_header'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -91,6 +92,7 @@ describe('Detection rules, threshold', () => { }); after(() => { + deleteRule(); esArchiverUnload('timeline'); }); @@ -162,14 +164,10 @@ describe('Detection rules, threshold', () => { ); }); - refreshPage(); waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) - .invoke('text') - .then((numberOfAlertsText) => { - cy.wrap(parseInt(numberOfAlertsText, 10)).should('be.below', 100); - }); + cy.get(NUMBER_OF_ALERTS).invoke('text').then(parseFloat).should('be.below', 100); cy.get(ALERT_RULE_NAME).first().should('have.text', newThresholdRule.name); cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threshold'); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index e1ab5ff30572f8..5e7dce6966195d 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -31,7 +31,11 @@ export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]'; export const CREATE_AND_ACTIVATE_BTN = '[data-test-subj="create-activate"]'; -export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; +export const CUSTOM_QUERY_INPUT = + '[data-test-subj="detectionEngineStepDefineRuleQueryBar"] [data-test-subj="queryInput"]'; + +export const THREAT_MATCH_QUERY_INPUT = + '[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]'; export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index 5a376e95e38dd3..e40b81ed0e8567 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ALL_ACTIONS = '[data-test-subj="rules-details-popover-button-icon"]'; + export const ABOUT_INVESTIGATION_NOTES = '[data-test-subj="stepAboutDetailsNoteContent"]'; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -24,6 +26,8 @@ export const DETAILS_DESCRIPTION = '.euiDescriptionList__description'; export const DETAILS_TITLE = '.euiDescriptionList__title'; +export const DELETE_RULE = '[data-test-subj=rules-details-delete-rule]'; + export const FALSE_POSITIVES_DETAILS = 'False positive examples'; export const INDEX_PATTERNS_DETAILS = 'Index patterns'; diff --git a/x-pack/plugins/security_solution/cypress/support/commands.js b/x-pack/plugins/security_solution/cypress/support/commands.js index 0dbcb1af4642f6..dbd60cdd31a5a6 100644 --- a/x-pack/plugins/security_solution/cypress/support/commands.js +++ b/x-pack/plugins/security_solution/cypress/support/commands.js @@ -69,3 +69,36 @@ Cypress.Commands.add( }); } ); + +const waitUntil = (subject, fn, options = {}) => { + const { interval = 200, timeout = 5000 } = options; + let attempts = Math.floor(timeout / interval); + + const completeOrRetry = (result) => { + if (result) { + return result; + } + if (attempts < 1) { + throw new Error(`Timed out while retrying, last result was: {${result}}`); + } + cy.wait(interval, { log: false }).then(() => { + attempts--; + // eslint-disable-next-line no-use-before-define + return evaluate(); + }); + }; + + const evaluate = () => { + const result = fn(subject); + + if (result && result.then) { + return result.then(completeOrRetry); + } else { + return completeOrRetry(result); + } + }; + + return evaluate(); +}; + +Cypress.Commands.add('waitUntil', { prevSubject: 'optional' }, waitUntil); diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index 59180507cbadee..0cf3cf614cdb9e 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -14,5 +14,12 @@ declare namespace Cypress { searchStrategyName?: string ): Chainable; attachFile(fileName: string, fileType?: string): Chainable; + waitUntil( + fn: (subject: Subject) => boolean | Chainable, + options?: { + interval: number; + timeout: number; + } + ): Chainable; } } diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 8b494edaade3a8..1c430e12b6b734 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -27,6 +27,7 @@ import { EDIT_RULE_ACTION_BTN, NEXT_BTN, } from '../screens/alerts_detection_rules'; +import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; export const activateRule = (rulePosition: number) => { cy.get(RULE_SWITCH).eq(rulePosition).click({ force: true }); @@ -47,6 +48,11 @@ export const deleteFirstRule = () => { cy.get(DELETE_RULE_ACTION_BTN).click(); }; +export const deleteRule = () => { + cy.get(ALL_ACTIONS).click(); + cy.get(DELETE_RULE).click(); +}; + export const deleteSelectedRules = () => { cy.get(BULK_ACTIONS_BTN).click({ force: true }); cy.get(DELETE_RULE_BULK_BTN).click(); diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index fa3c219595c729..5b2c365dfd8c34 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -11,6 +11,7 @@ import { OverrideRule, ThresholdRule, } from '../objects/rule'; +import { NUMBER_OF_ALERTS } from '../screens/alerts'; import { ABOUT_CONTINUE_BTN, ABOUT_EDIT_TAB, @@ -62,6 +63,7 @@ import { EQL_QUERY_INPUT, } from '../screens/create_new_rule'; import { TIMELINE } from '../screens/timelines'; +import { refreshPage } from './security_header'; export const createAndActivateRule = () => { cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); @@ -263,12 +265,27 @@ export const selectThresholdRuleType = () => { cy.get(THRESHOLD_TYPE).click({ force: true }); }; -export const waitForTheRuleToBeExecuted = async () => { - let status = ''; - while (status !== 'succeeded') { +export const waitForTheRuleToBeExecuted = () => { + cy.waitUntil(() => { cy.get(REFRESH_BUTTON).click(); - status = await cy.get(RULE_STATUS).invoke('text').promisify(); - } + return cy + .get(RULE_STATUS) + .invoke('text') + .then((ruleStatus) => ruleStatus === 'succeeded'); + }); +}; + +export const waitForAlertsToPopulate = async () => { + cy.waitUntil(() => { + refreshPage(); + return cy + .get(NUMBER_OF_ALERTS) + .invoke('text') + .then((countText) => { + const alertCount = parseInt(countText, 10) || 0; + return alertCount > 0; + }); + }); }; export const selectEqlRuleType = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_header.ts b/x-pack/plugins/security_solution/cypress/tasks/security_header.ts index 28efc47120d326..a28767b489e46d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_header.ts @@ -19,9 +19,9 @@ export const navigateFromHeaderTo = (page: string) => { }; export const refreshPage = () => { - cy.get(REFRESH_BUTTON).click({ force: true }).invoke('text').should('not.equal', 'Updating'); + cy.get(REFRESH_BUTTON).click({ force: true }).should('not.have.text', 'Updating'); }; export const waitForThePageToBeUpdated = () => { - cy.get(REFRESH_BUTTON).should('not.equal', 'Updating'); + cy.get(REFRESH_BUTTON).should('not.have.text', 'Updating'); }; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 1fea4bb1aba54a..145e34c4fc99ca 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -28,7 +28,8 @@ "usageCollection", "lists", "home", - "telemetry" + "telemetry", + "telemetryManagementSection" ], "server": true, "ui": true, diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 3e3d21b9926d14..5b77c4d99a9517 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -54,7 +54,7 @@ export const AddComment = React.memo( const fieldName = 'comment'; const { setFieldValue, reset, submit } = form; - const [{ comment }] = useFormData({ form, watch: [fieldName] }); + const [{ comment }] = useFormData<{ comment: string }>({ form, watch: [fieldName] }); const onCommentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ setFieldValue, diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index 45e46b2d7d2db8..5bffebbefa40fc 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -4,24 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiMarkdownFormat, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { - MarkdownEditorForm, - parsingPlugins, - processingPlugins, -} from '../../../common/components/markdown_editor/eui_form'; +import { MarkdownRenderer, MarkdownEditorForm } from '../../../common/components/markdown_editor'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -111,12 +101,7 @@ export const UserActionMarkdown = ({ ) : ( - - {content} - + {content} ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 152161e2ce3a43..60e7accf494587 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -104,6 +104,7 @@ interface Props { kqlMode: KqlMode; onChangeItemsPerPage: OnChangeItemsPerPage; query: Query; + onRuleChange?: () => void; start: string; sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; @@ -131,6 +132,7 @@ const EventsViewerComponent: React.FC = ({ kqlMode, onChangeItemsPerPage, query, + onRuleChange, start, sort, toggleColumn, @@ -286,6 +288,7 @@ const EventsViewerComponent: React.FC = ({ docValueFields={docValueFields} id={id} isEventViewer={true} + onRuleChange={onRuleChange} refetch={refetch} sort={sort} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index c53d311dc1361c..a4f2b0536abf5c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -42,6 +42,7 @@ export interface OwnProps { start: string; headerFilterGroup?: React.ReactNode; pageFilters?: Filter[]; + onRuleChange?: () => void; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; } @@ -64,6 +65,7 @@ const StatefulEventsViewerComponent: React.FC = ({ kqlMode, pageFilters, query, + onRuleChange, removeColumn, start, scopeId, @@ -153,6 +155,7 @@ const StatefulEventsViewerComponent: React.FC = ({ kqlMode={kqlMode} onChangeItemsPerPage={onChangeItemsPerPage} query={query} + onRuleChange={onRuleChange} start={start} sort={sort} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index c97895cdfe2363..f90c83bf953ea4 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -18,7 +18,7 @@ import { ExceptionListItemIdentifiers, Filter } from '../types'; import { allExceptionItemsReducer, State, ViewerModalName } from './reducer'; import { useExceptionList, - ExceptionIdentifiers, + ExceptionListIdentifiers, ExceptionListTypeEnum, ExceptionListItemSchema, UseExceptionListSuccess, @@ -54,7 +54,7 @@ interface ExceptionsViewerProps { ruleId: string; ruleName: string; ruleIndices: string[]; - exceptionListsMeta: ExceptionIdentifiers[]; + exceptionListsMeta: ExceptionListIdentifiers[]; availableListTypes: ExceptionListTypeEnum[]; commentsAccordionId: string; onRuleChange?: () => void; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts index ca7471b0f82396..9c2a89829b59e7 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/reducer.ts @@ -12,7 +12,7 @@ import { import { ExceptionListType, ExceptionListItemSchema, - ExceptionIdentifiers, + ExceptionListIdentifiers, Pagination, } from '../../../../../public/lists_plugin_deps'; @@ -36,7 +36,7 @@ export interface State { export type Action = | { type: 'setExceptions'; - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; exceptions: ExceptionListItemSchema[]; pagination: Pagination; } @@ -48,7 +48,7 @@ export type Action = | { type: 'updateModalOpen'; modalName: ViewerModalName } | { type: 'updateExceptionToEdit'; - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; exception: ExceptionListItemSchema; } | { type: 'updateLoadingItemIds'; items: ExceptionListItemIdentifiers[] } diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap index 80144311921700..d95e0300fe140e 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/__snapshots__/index.test.tsx.snap @@ -1,21 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`item_details_card ItemDetailsAction should render correctly 1`] = ` - +
primary - +
`; exports[`item_details_card ItemDetailsCard should render correctly with actions 1`] = ` diff --git a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx index 37003961d67d04..829d8db5a5a0f0 100644 --- a/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/item_details_card/index.tsx @@ -80,18 +80,12 @@ export const ItemDetailsPropertySummary = memo( ItemDetailsPropertySummary.displayName = 'ItemPropertySummary'; export const ItemDetailsAction: FC> = memo( - ({ children, ...rest }) => ( - <> - + ({ children, className = '', ...rest }) => ( +
+ {children} - +
) ); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4850547f30c527..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,58 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Markdown markdown links it renders the expected content containing a link 1`] = ` - -`; - -exports[`Markdown markdown tables it renders the expected table content 1`] = ` - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap deleted file mode 100644 index 7f350072439c52..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/__snapshots__/markdown_hint.test.tsx.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MarkdownHintComponent rendering it renders the expected hints 1`] = ` - - - # heading - - - **bold** - - - _italics_ - - - \`code\` - - - [link](url) - - - * bullet - - - \`\`\`preformatted\`\`\` - - - >quote - - ~~ - - strikethrough - - ~~ - - ![image](url) - - -`; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx deleted file mode 100644 index e30391982ee7a0..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/index.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { Markdown } from '.'; - -describe('Markdown', () => { - test(`it renders when raw markdown is NOT provided`, () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown"]').exists()).toEqual(true); - }); - - test('it renders plain text', () => { - const raw = 'this has no special markdown formatting'; - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-root"]').first().text()).toEqual(raw); - }); - - test('it applies the EUI text style to all markdown content', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="markdown-root"]').first().childAt(0).hasClass('euiText') - ).toBe(true); - }); - - describe('markdown tables', () => { - const headerColumns = ['we', 'support', 'markdown', 'tables']; - const header = `| ${headerColumns[0]} | ${headerColumns[1]} | ${headerColumns[2]} | ${headerColumns[3]} |`; - - const rawTable = `${header}\n|---------|---------|------------|--------|\n| because | tables | are | pretty |\n| useful | for | formatting | data |`; - - test('it applies EUI table styling to tables', () => { - const wrapper = mount(); - - expect(wrapper.find('table').first().childAt(0).hasClass('euiTable')).toBe(true); - }); - - headerColumns.forEach((headerText) => { - test(`it renders the "${headerText}" table header`, () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-table-header"]').first().text()).toContain( - headerText - ); - }); - }); - - test('it applies EUI table styling to table rows', () => { - const wrapper = mount(); - - expect( - wrapper - .find('[data-test-subj="markdown-table-row"]') - .first() - .childAt(0) - .hasClass('euiTableRow') - ).toBe(true); - }); - - test('it applies EUI table styling to table cells', () => { - const wrapper = mount(); - - expect( - wrapper - .find('[data-test-subj="markdown-table-cell"]') - .first() - .childAt(0) - .hasClass('euiTableRowCell') - ).toBe(true); - }); - - test('it renders the expected table content', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - }); - - describe('markdown links', () => { - const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; - - test('it renders the expected link text', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().text()).toEqual( - 'External Site' - ); - }); - - test('it renders the expected href', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'href', - 'https://google.com/' - ); - }); - - test('it does NOT render the href if links are disabled', () => { - const wrapper = mount(); - - expect( - wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() - ).not.toHaveProperty('href'); - }); - - test('it opens links in a new tab via target="_blank"', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'target', - '_blank' - ); - }); - - test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( - 'rel', - 'nofollow noopener noreferrer' - ); - }); - - test('it renders the expected content containing a link', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - - describe('markdown timeline links', () => { - const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; - const markdownWithTimelineLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; - const onClickTimeline = jest.fn(); - beforeEach(() => { - jest.resetAllMocks(); - }); - test('it renders a timeline link without href when provided the onClickTimeline argument', () => { - const wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="markdown-timeline-link"]').first().getDOMNode() - ).not.toHaveProperty('href'); - }); - test('timeline link onClick calls onClickTimeline with timelineId', () => { - const wrapper = mount( - - ); - wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click'); - - expect(onClickTimeline).toHaveBeenCalledWith(timelineId, ''); - }); - - test('timeline link onClick calls onClickTimeline with timelineId and graphEventId', () => { - const graphEventId = '2bc51864784c'; - const markdownWithTimelineAndGraphEventLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t,graphEventId:'${graphEventId}'))`; - - const wrapper = mount( - - ); - wrapper.find('[data-test-subj="markdown-timeline-link"]').first().simulate('click'); - - expect(onClickTimeline).toHaveBeenCalledWith(timelineId, graphEventId); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx deleted file mode 100644 index 1d73c3cb8a2aa5..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/index.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable react/display-name */ - -import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@elastic/eui'; -import { clone } from 'lodash/fp'; -import React from 'react'; -import ReactMarkdown from 'react-markdown'; -import styled, { css } from 'styled-components'; -import * as i18n from './translations'; - -const TableHeader = styled.thead` - font-weight: bold; -`; - -const MyBlockquote = styled.div` - ${({ theme }) => css` - padding: 0 ${theme.eui.euiSize}; - color: ${theme.eui.euiColorMediumShade}; - border-left: ${theme.eui.euiSizeXS} solid ${theme.eui.euiColorLightShade}; - `} -`; - -TableHeader.displayName = 'TableHeader'; - -/** prevents links to the new pages from accessing `window.opener` */ -const REL_NOOPENER = 'noopener'; - -/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ -const REL_NOFOLLOW = 'nofollow'; - -/** prevents the browser from sending the current address as referrer via the Referer HTTP header */ -const REL_NOREFERRER = 'noreferrer'; - -export const Markdown = React.memo<{ - disableLinks?: boolean; - raw?: string; - onClickTimeline?: (timelineId: string, graphEventId?: string) => void; - size?: 'xs' | 's' | 'm'; -}>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - - {children} - - ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => { - if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) { - const timelineId = clone(href).split('timeline=(id:')[1].split("'")[1] ?? ''; - const graphEventId = href.includes('graphEventId:') - ? clone(href).split('graphEventId:')[1].split("'")[1] ?? '' - : ''; - return ( - - onClickTimeline(timelineId, graphEventId)} - data-test-subj="markdown-timeline-link" - > - {children} - - - ); - } - return ( - - - {children} - - - ); - }, - blockquote: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - }; - - return ( - - ); -}); - -Markdown.displayName = 'Markdown'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx deleted file mode 100644 index 5ec37f8aed0cbd..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mount, shallow } from 'enzyme'; -import React from 'react'; - -import { MarkdownHintComponent } from './markdown_hint'; - -describe('MarkdownHintComponent ', () => { - test('it has inline visibility when show is true', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( - 'visibility', - 'inline' - ); - }); - - test('it has hidden visibility when show is false', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( - 'visibility', - 'hidden' - ); - }); - - test('it renders the heading hint', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="heading-hint"]').first().text()).toEqual('# heading'); - }); - - test('it renders the bold hint with a bold font-weight', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="bold-hint"]').first()).toHaveStyleRule( - 'font-weight', - 'bold' - ); - }); - - test('it renders the italic hint with an italic font-style', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="italic-hint"]').first()).toHaveStyleRule( - 'font-style', - 'italic' - ); - }); - - test('it renders the code hint with a monospace font family', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="code-hint"]').first()).toHaveStyleRule( - 'font-family', - 'monospace' - ); - }); - - test('it renders the preformatted hint with a monospace font family', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="preformatted-hint"]').first()).toHaveStyleRule( - 'font-family', - 'monospace' - ); - }); - - test('it renders the strikethrough hint with a line-through text-decoration', () => { - const wrapper = mount(); - - expect(wrapper.find('[data-test-subj="strikethrough-hint"]').first()).toHaveStyleRule( - 'text-decoration', - 'line-through' - ); - }); - - describe('rendering', () => { - test('it renders the expected hints', () => { - const wrapper = shallow(); - - expect(wrapper).toMatchSnapshot(); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx b/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx deleted file mode 100644 index 199059670e4bd2..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/markdown_hint.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiText } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from './translations'; - -const Heading = styled.span` - margin-right: 5px; -`; - -Heading.displayName = 'Heading'; - -const Bold = styled.span` - font-weight: bold; - margin-right: 5px; -`; - -Bold.displayName = 'Bold'; - -const MarkdownHintContainer = styled(EuiText)<{ visibility: string }>` - visibility: ${({ visibility }) => visibility}; -`; - -MarkdownHintContainer.displayName = 'MarkdownHintContainer'; - -const ImageUrl = styled.span` - margin-left: 5px; -`; - -ImageUrl.displayName = 'ImageUrl'; - -const Italic = styled.span` - font-style: italic; - margin-right: 5px; -`; - -Italic.displayName = 'Italic'; - -const Strikethrough = styled.span` - text-decoration: line-through; -`; - -Strikethrough.displayName = 'Strikethrough'; - -const Code = styled.span` - font-family: monospace; - margin-right: 5px; -`; - -Code.displayName = 'Code'; - -const TrailingWhitespace = styled.span` - margin-right: 5px; -`; - -TrailingWhitespace.displayName = 'TrailingWhitespace'; - -export const MarkdownHintComponent = ({ show }: { show: boolean }) => ( - - {i18n.MARKDOWN_HINT_HEADING} - {i18n.MARKDOWN_HINT_BOLD} - {i18n.MARKDOWN_HINT_ITALICS} - {i18n.MARKDOWN_HINT_CODE} - {i18n.MARKDOWN_HINT_URL} - {i18n.MARKDOWN_HINT_BULLET} - {i18n.MARKDOWN_HINT_PREFORMATTED} - {i18n.MARKDOWN_HINT_QUOTE} - {'~~'} - - {i18n.MARKDOWN_HINT_STRIKETHROUGH} - - {'~~'} - {i18n.MARKDOWN_HINT_IMAGE_URL} - -); - -MarkdownHintComponent.displayName = 'MarkdownHintComponent'; - -export const MarkdownHint = React.memo(MarkdownHintComponent); - -MarkdownHint.displayName = 'MarkdownHint'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts b/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts deleted file mode 100644 index 98d2e7d47b3fbc..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/markdown/translations.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const MARKDOWN_HINT_HEADING = i18n.translate( - 'xpack.securitySolution.markdown.hint.headingLabel', - { - defaultMessage: '# heading', - } -); - -export const MARKDOWN_HINT_BOLD = i18n.translate('xpack.securitySolution.markdown.hint.boldLabel', { - defaultMessage: '**bold**', -}); - -export const MARKDOWN_HINT_ITALICS = i18n.translate( - 'xpack.securitySolution.markdown.hint.italicsLabel', - { - defaultMessage: '_italics_', - } -); - -export const MARKDOWN_HINT_CODE = i18n.translate('xpack.securitySolution.markdown.hint.codeLabel', { - defaultMessage: '`code`', -}); - -export const MARKDOWN_HINT_URL = i18n.translate('xpack.securitySolution.markdown.hint.urlLabel', { - defaultMessage: '[link](url)', -}); - -export const MARKDOWN_HINT_BULLET = i18n.translate( - 'xpack.securitySolution.markdown.hint.bulletLabel', - { - defaultMessage: '* bullet', - } -); - -export const MARKDOWN_HINT_PREFORMATTED = i18n.translate( - 'xpack.securitySolution.markdown.hint.preformattedLabel', - { - defaultMessage: '```preformatted```', - } -); - -export const MARKDOWN_HINT_QUOTE = i18n.translate( - 'xpack.securitySolution.markdown.hint.quoteLabel', - { - defaultMessage: '>quote', - } -); - -export const MARKDOWN_HINT_STRIKETHROUGH = i18n.translate( - 'xpack.securitySolution.markdown.hint.strikethroughLabel', - { - defaultMessage: 'strikethrough', - } -); - -export const MARKDOWN_HINT_IMAGE_URL = i18n.translate( - 'xpack.securitySolution.markdown.hint.imageUrlLabel', - { - defaultMessage: '![image](url)', - } -); - -export const TIMELINE_ID = (timelineId: string) => - i18n.translate('xpack.securitySolution.markdown.toolTip.timelineId', { - defaultMessage: 'Timeline id: { timelineId }', - values: { - timelineId, - }, - }); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx new file mode 100644 index 00000000000000..b8632de71abe12 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/editor.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useState, useCallback } from 'react'; +import { EuiMarkdownEditor } from '@elastic/eui'; + +import { uiPlugins, parsingPlugins, processingPlugins } from './plugins'; + +interface MarkdownEditorProps { + onChange: (content: string) => void; + value: string; + ariaLabel: string; + editorId?: string; + dataTestSubj?: string; + height?: number; +} + +const MarkdownEditorComponent: React.FC = ({ + onChange, + value, + ariaLabel, + editorId, + dataTestSubj, + height, +}) => { + const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); + const onParse = useCallback((err, { messages }) => { + setMarkdownErrorMessages(err ? [err] : messages); + }, []); + + return ( + + ); +}; + +export const MarkdownEditor = memo(MarkdownEditorComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx index 481ed7892a8be0..a28abbc8a59e49 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/eui_form.tsx @@ -4,20 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import styled from 'styled-components'; -import { - EuiMarkdownEditor, - EuiMarkdownEditorProps, - EuiFormRow, - EuiFlexItem, - EuiFlexGroup, - getDefaultEuiMarkdownParsingPlugins, - getDefaultEuiMarkdownProcessingPlugins, -} from '@elastic/eui'; +import { EuiMarkdownEditorProps, EuiFormRow, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; -import * as timelineMarkdownPlugin from './plugins/timeline'; +import { MarkdownEditor } from './editor'; type MarkdownEditorFormProps = EuiMarkdownEditorProps & { id: string; @@ -34,12 +26,6 @@ const BottomContentWrapper = styled(EuiFlexGroup)` `} `; -export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); -parsingPlugins.push(timelineMarkdownPlugin.parser); - -export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); -processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; - export const MarkdownEditorForm: React.FC = ({ id, field, @@ -48,10 +34,6 @@ export const MarkdownEditorForm: React.FC = ({ bottomRightContent, }) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - const [markdownErrorMessages, setMarkdownErrorMessages] = useState([]); - const onParse = useCallback((err, { messages }) => { - setMarkdownErrorMessages(err ? [err] : messages); - }, []); return ( = ({ labelAppend={field.labelAppend} > <> - {bottomRightContent && ( diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx index 9f4141dbcae7df..41f5aab691a7a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/index.tsx @@ -5,3 +5,6 @@ */ export * from './types'; +export * from './renderer'; +export * from './editor'; +export * from './eui_form'; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.tsx new file mode 100644 index 00000000000000..f904b63d4bacea --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/markdown_link.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiLink, EuiLinkAnchorProps, EuiToolTip } from '@elastic/eui'; + +type MarkdownLinkProps = { disableLinks?: boolean } & EuiLinkAnchorProps; + +/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ +const REL_NOFOLLOW = 'nofollow'; + +const MarkdownLinkComponent: React.FC = ({ + disableLinks, + href, + target, + children, + ...props +}) => ( + + + {children} + + +); + +export const MarkdownLink = memo(MarkdownLinkComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts new file mode 100644 index 00000000000000..b3d91d26e50da4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getDefaultEuiMarkdownParsingPlugins, + getDefaultEuiMarkdownProcessingPlugins, +} from '@elastic/eui'; + +import * as timelineMarkdownPlugin from './timeline'; + +export const uiPlugins = [timelineMarkdownPlugin.plugin]; +export const parsingPlugins = getDefaultEuiMarkdownParsingPlugins(); +export const processingPlugins = getDefaultEuiMarkdownProcessingPlugins(); + +parsingPlugins.push(timelineMarkdownPlugin.parser); + +// This line of code is TS-compatible and it will break if [1][1] change in the future. +processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer; diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx new file mode 100644 index 00000000000000..e6a38863d7e5f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { MarkdownRenderer } from './renderer'; + +describe('Markdown', () => { + describe('markdown links', () => { + const markdownWithLink = 'A link to an external site [External Site](https://google.com)'; + + test('it renders the expected link text', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().text()).toEqual( + 'External Site' + ); + }); + + test('it renders the expected href', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'href', + 'https://google.com/' + ); + }); + + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount( + {markdownWithLink} + ); + + expect( + wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode() + ).not.toHaveProperty('href'); + }); + + test('it opens links in a new tab via target="_blank"', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'target', + '_blank' + ); + }); + + test('it sets the link `rel` attribute to `noopener` to prevent the new page from accessing `window.opener`, `nofollow` to note the link is not endorsed by us, and noreferrer to prevent the browser from sending the current address', () => { + const wrapper = mount({markdownWithLink}); + + expect(wrapper.find('[data-test-subj="markdown-link"]').first().getDOMNode()).toHaveProperty( + 'rel', + 'nofollow noopener noreferrer' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx new file mode 100644 index 00000000000000..7a7693512afbe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/renderer.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo } from 'react'; +import { cloneDeep } from 'lodash/fp'; +import { EuiMarkdownFormat, EuiLinkAnchorProps } from '@elastic/eui'; + +import { parsingPlugins, processingPlugins } from './plugins'; +import { MarkdownLink } from './markdown_link'; + +interface Props { + children: string; + disableLinks?: boolean; +} + +const MarkdownRendererComponent: React.FC = ({ children, disableLinks }) => { + const MarkdownLinkProcessingComponent: React.FC = useMemo( + () => (props) => , + [disableLinks] + ); + + // Deep clone of the processing plugins to prevent affecting the markdown editor. + const processingPluginList = cloneDeep(processingPlugins); + // This line of code is TS-compatible and it will break if [1][1] change in the future. + processingPluginList[1][1].components.a = MarkdownLinkProcessingComponent; + + return ( + + {children} + + ); +}; + +export const MarkdownRenderer = memo(MarkdownRendererComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx index 54e6e1cdc11852..772b8b94ad033c 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.test.tsx @@ -174,6 +174,29 @@ describe('Sourcerer component', () => { wrapper.find(`[data-test-subj="sourcerer-reset"]`).first().simulate('click'); expect(wrapper.find(`[data-test-subj="config-option"]`).first().exists()).toBeFalsy(); }); + it('disables saving when no index patterns are selected', () => { + store = createStore( + { + ...state, + sourcerer: { + ...state.sourcerer, + kibanaIndexPatterns: [{ id: '1234', title: 'auditbeat-*' }], + }, + }, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="sourcerer-trigger"]').first().simulate('click'); + wrapper.find('[data-test-subj="comboBoxClearButton"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="add-index"]').first().prop('disabled')).toBeTruthy(); + }); it('returns index pattern options for kibanaIndexPatterns and configIndexPatterns', () => { store = createStore( { diff --git a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx index bc091167983447..52161130d5b3ed 100644 --- a/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sourcerer/index.tsx @@ -54,6 +54,7 @@ export const Sourcerer = React.memo(({ scope: scopeId } value: indexSelected, })) ); + const isSavingDisabled = useMemo(() => selectedOptions.length === 0, [selectedOptions]); const setPopoverIsOpenCb = useCallback(() => setPopoverIsOpen((prevState) => !prevState), []); @@ -205,6 +206,7 @@ export const Sourcerer = React.memo(({ scope: scopeId } > => ({ + id: 'some-id', + rawResponse: { + body: { + hits: { + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T15:50:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T15:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T15:15:54.368707900Z', + }, + }, + ], + total: { + value: 4, + relation: '', + }, + }, + is_partial: false, + is_running: false, + took: 300, + timed_out: false, + }, + headers: {}, + warnings: [], + meta: { + aborted: false, + attempts: 0, + context: null, + name: 'elasticsearch-js', + connection: {} as Connection, + request: { + params: { + body: JSON.stringify({ + filter: { + range: { + '@timestamp': { + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + format: 'strict_date_optional_time', + }, + }, + }, + }), + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }, + options: {}, + id: '', + }, + }, + statusCode: 200, + }, +}); + +export const getMockEqlSequenceResponse = (): EqlSearchStrategyResponse< + EqlSearchResponse +> => ({ + id: 'some-id', + rawResponse: { + body: { + hits: { + sequences: [ + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T15:50:54.368707900Z', + }, + }, + ], + }, + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T15:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T15:15:54.368707900Z', + }, + }, + ], + }, + ], + total: { + value: 4, + relation: '', + }, + }, + is_partial: false, + is_running: false, + took: 300, + timed_out: false, + }, + headers: {}, + warnings: [], + meta: { + aborted: false, + attempts: 0, + context: null, + name: 'elasticsearch-js', + connection: {} as Connection, + request: { + params: { + body: JSON.stringify({ + filter: { + range: { + '@timestamp': { + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + format: 'strict_date_optional_time', + }, + }, + }, + }), + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }, + options: {}, + id: '', + }, + }, + statusCode: 200, + }, +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts index 07e8caa0bf0b96..6ba2eaa3d3c30c 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.test.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import dateMath from '@elastic/datemath'; import moment from 'moment'; import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common'; @@ -17,175 +16,44 @@ import { getEqlAggsData, createIntervalArray, getInterval, - getSequenceAggs, + formatInspect, + getEventsToBucket, } from './helpers'; - -export const getMockResponse = (): EqlSearchStrategyResponse> => - ({ - id: 'some-id', - rawResponse: { - body: { - hits: { - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T15:50:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T15:06:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T15:15:54.368707900Z', - }, - }, - ], - total: { - value: 4, - relation: '', - }, - }, - }, - meta: { - request: { - params: { - method: 'GET', - path: '/_eql/search/', - }, - options: {}, - id: '', - }, - }, - statusCode: 200, - }, - } as EqlSearchStrategyResponse>); - -const getMockSequenceResponse = (): EqlSearchStrategyResponse> => - (({ - id: 'some-id', - rawResponse: { - body: { - hits: { - sequences: [ - { - join_keys: [], - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T15:50:54.368707900Z', - }, - }, - ], - }, - { - join_keys: [], - events: [ - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T15:06:54.368707900Z', - }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T15:15:54.368707900Z', - }, - }, - ], - }, - ], - total: { - value: 4, - relation: '', - }, - }, - }, - meta: { - request: { - params: { - body: JSON.stringify({ - filter: { - range: { - '@timestamp': { - gte: '2020-10-07T00:46:12.414Z', - lte: '2020-10-07T01:46:12.414Z', - format: 'strict_date_optional_time', - }, - }, - }, - }), - method: 'GET', - path: '/_eql/search/', - }, - options: {}, - id: '', - }, - }, - statusCode: 200, - }, - } as unknown) as EqlSearchStrategyResponse>); +import { getMockEqlResponse, getMockEqlSequenceResponse } from './eql_search_response.mock'; describe('eql/helpers', () => { describe('calculateBucketForHour', () => { - test('returns 2 if event occurred within 2 minutes of "now"', () => { + test('returns 2 if the difference in times is 2 minutes', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now-1m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:56:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(2); }); - test('returns 10 if event occurred within 8-10 minutes of "now"', () => { + test('returns 10 if the difference in times is 8-10 minutes', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now-9m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:48:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(10); }); - test('returns 16 if event occurred within 10-15 minutes of "now"', () => { + test('returns 16 if the difference in times is 10-15 minutes', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now-15m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:42:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(16); }); - test('returns 60 if event occurred within 58-60 minutes of "now"', () => { + test('returns 60 if the difference in times is 58-60 minutes', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now-59m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T04:58:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(60); @@ -193,8 +61,8 @@ describe('eql/helpers', () => { test('returns exact time difference if it is a multiple of 2', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now-20m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:37:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(20); @@ -202,251 +70,488 @@ describe('eql/helpers', () => { test('returns 0 if times are equal', () => { const diff = calculateBucketForHour( - Number(dateMath.parse('now')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:57:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(0); }); + + test('returns 2 if the difference in times is 2 minutes but arguments are flipped', () => { + const diff = calculateBucketForHour( + Date.parse('2020-02-20T05:57:54.037Z'), + Date.parse('2020-02-20T05:56:54.037Z') + ); + + expect(diff).toEqual(2); + }); }); describe('calculateBucketForDay', () => { test('returns 0 if two dates are equivalent', () => { const diff = calculateBucketForDay( - Number(dateMath.parse('now')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:57:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(0); }); - test('returns 1 if event occurred within 60 minutes of "now"', () => { + test('returns 1 if the difference in times is 60 minutes', () => { const diff = calculateBucketForDay( - Number(dateMath.parse('now-40m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T05:17:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(1); }); - test('returns 2 if event occurred 60-120 minutes from "now"', () => { + test('returns 2 if the difference in times is 60-120 minutes', () => { const diff = calculateBucketForDay( - Number(dateMath.parse('now-120m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T03:57:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(2); }); - test('returns 3 if event occurred 120-180 minutes from "now', () => { + test('returns 3 if the difference in times is 120-180 minutes', () => { const diff = calculateBucketForDay( - Number(dateMath.parse('now-121m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T03:56:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(3); }); - test('returns 4 if event occurred 180-240 minutes from "now', () => { + test('returns 4 if the difference in times is 180-240 minutes', () => { const diff = calculateBucketForDay( - Number(dateMath.parse('now-220m')?.format('x')), - Number(dateMath.parse('now')?.format('x')) + Date.parse('2020-02-20T02:15:54.037Z'), + Date.parse('2020-02-20T05:57:54.037Z') ); expect(diff).toEqual(4); }); - }); - describe('getEqlAggsData', () => { - test('it returns results bucketed into 2 min intervals when range is "h"', () => { - const mockResponse = getMockResponse(); - - const aggs = getEqlAggsData( - mockResponse, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch + test('returns 2 if the difference in times is 60-120 minutes but arguments are flipped', () => { + const diff = calculateBucketForDay( + Date.parse('2020-02-20T05:57:54.037Z'), + Date.parse('2020-02-20T03:59:54.037Z') ); - const date1 = moment(aggs.data[0].x); - const date2 = moment(aggs.data[1].x); - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(120000); - expect(aggs.data).toHaveLength(31); - expect(aggs.data).toEqual([ - { g: 'hits', x: 1601827200368, y: 0 }, - { g: 'hits', x: 1601827080368, y: 0 }, - { g: 'hits', x: 1601826960368, y: 0 }, - { g: 'hits', x: 1601826840368, y: 0 }, - { g: 'hits', x: 1601826720368, y: 0 }, - { g: 'hits', x: 1601826600368, y: 1 }, - { g: 'hits', x: 1601826480368, y: 0 }, - { g: 'hits', x: 1601826360368, y: 0 }, - { g: 'hits', x: 1601826240368, y: 0 }, - { g: 'hits', x: 1601826120368, y: 0 }, - { g: 'hits', x: 1601826000368, y: 0 }, - { g: 'hits', x: 1601825880368, y: 0 }, - { g: 'hits', x: 1601825760368, y: 0 }, - { g: 'hits', x: 1601825640368, y: 0 }, - { g: 'hits', x: 1601825520368, y: 0 }, - { g: 'hits', x: 1601825400368, y: 0 }, - { g: 'hits', x: 1601825280368, y: 0 }, - { g: 'hits', x: 1601825160368, y: 0 }, - { g: 'hits', x: 1601825040368, y: 0 }, - { g: 'hits', x: 1601824920368, y: 0 }, - { g: 'hits', x: 1601824800368, y: 0 }, - { g: 'hits', x: 1601824680368, y: 0 }, - { g: 'hits', x: 1601824560368, y: 2 }, - { g: 'hits', x: 1601824440368, y: 0 }, - { g: 'hits', x: 1601824320368, y: 0 }, - { g: 'hits', x: 1601824200368, y: 0 }, - { g: 'hits', x: 1601824080368, y: 0 }, - { g: 'hits', x: 1601823960368, y: 1 }, - { g: 'hits', x: 1601823840368, y: 0 }, - { g: 'hits', x: 1601823720368, y: 0 }, - { g: 'hits', x: 1601823600368, y: 0 }, - ]); + expect(diff).toEqual(2); }); + }); - test('it returns results bucketed into 1 hour intervals when range is "d"', () => { - const mockResponse = getMockResponse(); - const response = { - ...mockResponse, - rawResponse: { - ...mockResponse.rawResponse, - body: { - is_partial: false, - is_running: false, - timed_out: false, - took: 15, - hits: { - events: [ - { - _index: 'index', - _id: '1', - _source: { - '@timestamp': '2020-10-04T15:16:54.368707900Z', + describe('getEqlAggsData', () => { + describe('non-sequence', () => { + test('it returns results bucketed into 2 min intervals when range is "h"', () => { + const mockResponse = getMockEqlResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This will be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(120000); + expect(aggs.data).toHaveLength(31); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601827200368, y: 0 }, + { g: 'hits', x: 1601827080368, y: 0 }, + { g: 'hits', x: 1601826960368, y: 0 }, + { g: 'hits', x: 1601826840368, y: 0 }, + { g: 'hits', x: 1601826720368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 1 }, + { g: 'hits', x: 1601826480368, y: 0 }, + { g: 'hits', x: 1601826360368, y: 0 }, + { g: 'hits', x: 1601826240368, y: 0 }, + { g: 'hits', x: 1601826120368, y: 0 }, + { g: 'hits', x: 1601826000368, y: 0 }, + { g: 'hits', x: 1601825880368, y: 0 }, + { g: 'hits', x: 1601825760368, y: 0 }, + { g: 'hits', x: 1601825640368, y: 0 }, + { g: 'hits', x: 1601825520368, y: 0 }, + { g: 'hits', x: 1601825400368, y: 0 }, + { g: 'hits', x: 1601825280368, y: 0 }, + { g: 'hits', x: 1601825160368, y: 0 }, + { g: 'hits', x: 1601825040368, y: 0 }, + { g: 'hits', x: 1601824920368, y: 0 }, + { g: 'hits', x: 1601824800368, y: 0 }, + { g: 'hits', x: 1601824680368, y: 0 }, + { g: 'hits', x: 1601824560368, y: 2 }, + { g: 'hits', x: 1601824440368, y: 0 }, + { g: 'hits', x: 1601824320368, y: 0 }, + { g: 'hits', x: 1601824200368, y: 0 }, + { g: 'hits', x: 1601824080368, y: 0 }, + { g: 'hits', x: 1601823960368, y: 1 }, + { g: 'hits', x: 1601823840368, y: 0 }, + { g: 'hits', x: 1601823720368, y: 0 }, + { g: 'hits', x: 1601823600368, y: 0 }, + ]); + }); + + test('it returns results bucketed into 1 hour intervals when range is "d"', () => { + const mockResponse = getMockEqlResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '2', - _source: { - '@timestamp': '2020-10-04T05:50:54.368707900Z', + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T05:50:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '3', - _source: { - '@timestamp': '2020-10-04T18:06:54.368707900Z', + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T18:06:54.368707900Z', + }, }, - }, - { - _index: 'index', - _id: '4', - _source: { - '@timestamp': '2020-10-04T23:15:54.368707900Z', + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T23:15:54.368707900Z', + }, }, + ], + total: { + value: 4, + relation: '', }, - ], - total: { - value: 4, - relation: '', }, }, }, - }, - }; - - const aggs = getEqlAggsData( - response, - 'd', - '2020-10-04T23:50:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - const date1 = moment(aggs.data[0].x); - const date2 = moment(aggs.data[1].x); - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(3600000); - expect(aggs.data).toHaveLength(25); - expect(aggs.data).toEqual([ - { g: 'hits', x: 1601855400368, y: 0 }, - { g: 'hits', x: 1601851800368, y: 1 }, - { g: 'hits', x: 1601848200368, y: 0 }, - { g: 'hits', x: 1601844600368, y: 0 }, - { g: 'hits', x: 1601841000368, y: 0 }, - { g: 'hits', x: 1601837400368, y: 0 }, - { g: 'hits', x: 1601833800368, y: 1 }, - { g: 'hits', x: 1601830200368, y: 0 }, - { g: 'hits', x: 1601826600368, y: 0 }, - { g: 'hits', x: 1601823000368, y: 1 }, - { g: 'hits', x: 1601819400368, y: 0 }, - { g: 'hits', x: 1601815800368, y: 0 }, - { g: 'hits', x: 1601812200368, y: 0 }, - { g: 'hits', x: 1601808600368, y: 0 }, - { g: 'hits', x: 1601805000368, y: 0 }, - { g: 'hits', x: 1601801400368, y: 0 }, - { g: 'hits', x: 1601797800368, y: 0 }, - { g: 'hits', x: 1601794200368, y: 0 }, - { g: 'hits', x: 1601790600368, y: 1 }, - { g: 'hits', x: 1601787000368, y: 0 }, - { g: 'hits', x: 1601783400368, y: 0 }, - { g: 'hits', x: 1601779800368, y: 0 }, - { g: 'hits', x: 1601776200368, y: 0 }, - { g: 'hits', x: 1601772600368, y: 0 }, - { g: 'hits', x: 1601769000368, y: 0 }, - ]); - }); - - test('it correctly returns total hits', () => { - const mockResponse = getMockResponse(); - - const aggs = getEqlAggsData( - mockResponse, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - - expect(aggs.totalCount).toEqual(4); + }; + + const aggs = getEqlAggsData( + response, + 'd', + '2020-10-04T23:50:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This'll be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(3600000); + expect(aggs.data).toHaveLength(25); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601855400368, y: 0 }, + { g: 'hits', x: 1601851800368, y: 1 }, + { g: 'hits', x: 1601848200368, y: 0 }, + { g: 'hits', x: 1601844600368, y: 0 }, + { g: 'hits', x: 1601841000368, y: 0 }, + { g: 'hits', x: 1601837400368, y: 0 }, + { g: 'hits', x: 1601833800368, y: 1 }, + { g: 'hits', x: 1601830200368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 0 }, + { g: 'hits', x: 1601823000368, y: 1 }, + { g: 'hits', x: 1601819400368, y: 0 }, + { g: 'hits', x: 1601815800368, y: 0 }, + { g: 'hits', x: 1601812200368, y: 0 }, + { g: 'hits', x: 1601808600368, y: 0 }, + { g: 'hits', x: 1601805000368, y: 0 }, + { g: 'hits', x: 1601801400368, y: 0 }, + { g: 'hits', x: 1601797800368, y: 0 }, + { g: 'hits', x: 1601794200368, y: 0 }, + { g: 'hits', x: 1601790600368, y: 1 }, + { g: 'hits', x: 1601787000368, y: 0 }, + { g: 'hits', x: 1601783400368, y: 0 }, + { g: 'hits', x: 1601779800368, y: 0 }, + { g: 'hits', x: 1601776200368, y: 0 }, + { g: 'hits', x: 1601772600368, y: 0 }, + { g: 'hits', x: 1601769000368, y: 0 }, + ]); + }); + + test('it correctly returns total hits', () => { + const mockResponse = getMockEqlResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + expect(aggs.totalCount).toEqual(4); + }); + + test('it returns array with each item having a "total" of 0 if response returns no hits', () => { + const mockResponse = getMockEqlResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + + const aggs = getEqlAggsData( + response, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + false + ); + + expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); + expect(aggs.totalCount).toEqual(0); + }); }); - test('it returns array with each item having a "total" of 0 if response returns no hits', () => { - const mockResponse = getMockResponse(); - const response = { - ...mockResponse, - rawResponse: { - ...mockResponse.rawResponse, - body: { - id: 'some-id', - is_partial: false, - is_running: false, - timed_out: false, - took: 15, - hits: { - total: { - value: 0, - relation: '', + describe('sequence', () => { + test('it returns results bucketed into 2 min intervals when range is "h"', () => { + const mockResponse = getMockEqlSequenceResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This will be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(120000); + expect(aggs.data).toHaveLength(31); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601827200368, y: 0 }, + { g: 'hits', x: 1601827080368, y: 0 }, + { g: 'hits', x: 1601826960368, y: 0 }, + { g: 'hits', x: 1601826840368, y: 0 }, + { g: 'hits', x: 1601826720368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 1 }, + { g: 'hits', x: 1601826480368, y: 0 }, + { g: 'hits', x: 1601826360368, y: 0 }, + { g: 'hits', x: 1601826240368, y: 0 }, + { g: 'hits', x: 1601826120368, y: 0 }, + { g: 'hits', x: 1601826000368, y: 0 }, + { g: 'hits', x: 1601825880368, y: 0 }, + { g: 'hits', x: 1601825760368, y: 0 }, + { g: 'hits', x: 1601825640368, y: 0 }, + { g: 'hits', x: 1601825520368, y: 0 }, + { g: 'hits', x: 1601825400368, y: 0 }, + { g: 'hits', x: 1601825280368, y: 0 }, + { g: 'hits', x: 1601825160368, y: 0 }, + { g: 'hits', x: 1601825040368, y: 0 }, + { g: 'hits', x: 1601824920368, y: 0 }, + { g: 'hits', x: 1601824800368, y: 0 }, + { g: 'hits', x: 1601824680368, y: 0 }, + { g: 'hits', x: 1601824560368, y: 1 }, + { g: 'hits', x: 1601824440368, y: 0 }, + { g: 'hits', x: 1601824320368, y: 0 }, + { g: 'hits', x: 1601824200368, y: 0 }, + { g: 'hits', x: 1601824080368, y: 0 }, + { g: 'hits', x: 1601823960368, y: 0 }, + { g: 'hits', x: 1601823840368, y: 0 }, + { g: 'hits', x: 1601823720368, y: 0 }, + { g: 'hits', x: 1601823600368, y: 0 }, + ]); + }); + + test('it returns results bucketed into 1 hour intervals when range is "d"', () => { + const mockResponse = getMockEqlSequenceResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + sequences: [ + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '1', + _source: { + '@timestamp': '2020-10-04T15:16:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '2', + _source: { + '@timestamp': '2020-10-04T05:50:54.368707900Z', + }, + }, + ], + }, + { + join_keys: [], + events: [ + { + _index: 'index', + _id: '3', + _source: { + '@timestamp': '2020-10-04T18:06:54.368707900Z', + }, + }, + { + _index: 'index', + _id: '4', + _source: { + '@timestamp': '2020-10-04T23:15:54.368707900Z', + }, + }, + ], + }, + ], + total: { + value: 4, + relation: '', + }, }, }, }, - }, - }; - - const aggs = getEqlAggsData( - response, - 'h', - '2020-10-04T16:00:00.368707900Z', - jest.fn() as inputsModel.Refetch - ); - - expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); - expect(aggs.totalCount).toEqual(0); + }; + + const aggs = getEqlAggsData( + response, + 'd', + '2020-10-04T23:50:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + const date1 = moment(aggs.data[0].x); + const date2 = moment(aggs.data[1].x); + // This'll be in ms + const diff = date1.diff(date2); + + expect(diff).toEqual(3600000); + expect(aggs.data).toHaveLength(25); + expect(aggs.data).toEqual([ + { g: 'hits', x: 1601855400368, y: 0 }, + { g: 'hits', x: 1601851800368, y: 1 }, + { g: 'hits', x: 1601848200368, y: 0 }, + { g: 'hits', x: 1601844600368, y: 0 }, + { g: 'hits', x: 1601841000368, y: 0 }, + { g: 'hits', x: 1601837400368, y: 0 }, + { g: 'hits', x: 1601833800368, y: 0 }, + { g: 'hits', x: 1601830200368, y: 0 }, + { g: 'hits', x: 1601826600368, y: 0 }, + { g: 'hits', x: 1601823000368, y: 0 }, + { g: 'hits', x: 1601819400368, y: 0 }, + { g: 'hits', x: 1601815800368, y: 0 }, + { g: 'hits', x: 1601812200368, y: 0 }, + { g: 'hits', x: 1601808600368, y: 0 }, + { g: 'hits', x: 1601805000368, y: 0 }, + { g: 'hits', x: 1601801400368, y: 0 }, + { g: 'hits', x: 1601797800368, y: 0 }, + { g: 'hits', x: 1601794200368, y: 0 }, + { g: 'hits', x: 1601790600368, y: 1 }, + { g: 'hits', x: 1601787000368, y: 0 }, + { g: 'hits', x: 1601783400368, y: 0 }, + { g: 'hits', x: 1601779800368, y: 0 }, + { g: 'hits', x: 1601776200368, y: 0 }, + { g: 'hits', x: 1601772600368, y: 0 }, + { g: 'hits', x: 1601769000368, y: 0 }, + ]); + }); + + test('it correctly returns total hits', () => { + const mockResponse = getMockEqlSequenceResponse(); + + const aggs = getEqlAggsData( + mockResponse, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + expect(aggs.totalCount).toEqual(4); + }); + + test('it returns array with each item having a "total" of 0 if response returns no hits', () => { + const mockResponse = getMockEqlSequenceResponse(); + const response: EqlSearchStrategyResponse> = { + ...mockResponse, + rawResponse: { + ...mockResponse.rawResponse, + body: { + is_partial: false, + is_running: false, + timed_out: false, + took: 15, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + + const aggs = getEqlAggsData( + response, + 'h', + '2020-10-04T16:00:00.368707900Z', + jest.fn() as inputsModel.Refetch, + ['foo-*'], + true + ); + + expect(aggs.data.every(({ y }) => y === 0)).toBeTruthy(); + expect(aggs.totalCount).toEqual(0); + }); }); }); @@ -456,41 +561,9 @@ describe('eql/helpers', () => { expect(arrayOfNumbers).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]); }); - test('returns array of 30 numbers from 0 to 60 by 2', () => { - const arrayOfNumbers = createIntervalArray(0, 30, 2); - expect(arrayOfNumbers).toEqual([ - 0, - 2, - 4, - 6, - 8, - 10, - 12, - 14, - 16, - 18, - 20, - 22, - 24, - 26, - 28, - 30, - 32, - 34, - 36, - 38, - 40, - 42, - 44, - 46, - 48, - 50, - 52, - 54, - 56, - 58, - 60, - ]); + test('returns array of 5 numbers from 0 to 10 by 2', () => { + const arrayOfNumbers = createIntervalArray(0, 5, 2); + expect(arrayOfNumbers).toEqual([0, 2, 4, 6, 8, 10]); }); test('returns array of numbers from start param to end param if multiplier is 1', () => { @@ -500,103 +573,46 @@ describe('eql/helpers', () => { }); describe('getInterval', () => { - test('returns object with 2 minute interval keys if range is "h"', () => { - const intervals = getInterval('h', Date.parse('2020-10-04T15:00:00.368707900Z')); - const keys = Object.keys(intervals); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['2'].timestamp)); - - // This'll be in ms - const diff = date1.diff(date2); - - expect(diff).toEqual(120000); - expect(keys).toEqual([ - '0', - '2', - '4', - '6', - '8', - '10', - '12', - '14', - '16', - '18', - '20', - '22', - '24', - '26', - '28', - '30', - '32', - '34', - '36', - '38', - '40', - '42', - '44', - '46', - '48', - '50', - '52', - '54', - '56', - '58', - '60', - ]); - }); - test('returns object with 2 minute interval timestamps if range is "h"', () => { const intervals = getInterval('h', 1601856270140); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['2'].timestamp)); - // This'll be in ms - const diff = date1.diff(date2); + const allAre2MinApart = Object.keys(intervals).every((int) => { + const interval1 = intervals[int]; + const interval2 = intervals[`${Number(int) + 2}`]; + if (interval1 != null && interval2 != null) { + const date1 = moment(Number(interval1.timestamp)); + const date2 = moment(Number(interval2.timestamp)); + // This'll be in ms + const diff = date1.diff(date2); - expect(diff).toEqual(120000); - }); + return diff === 120000; + } - test('returns object with 1 hour interval keys if range is "d"', () => { - const intervals = getInterval('d', 1601856270140); - const keys = Object.keys(intervals); - expect(keys).toEqual([ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '19', - '20', - '21', - '22', - '23', - '24', - ]); + return true; + }); + + expect(allAre2MinApart).toBeTruthy(); }); test('returns object with 1 hour interval timestamps if range is "d"', () => { const intervals = getInterval('d', 1601856270140); - const date1 = moment(Number(intervals['0'].timestamp)); - const date2 = moment(Number(intervals['1'].timestamp)); - // This'll be in ms - const diff = date1.diff(date2); + const allAre1HourApart = Object.keys(intervals).every((int) => { + const interval1 = intervals[int]; + const interval2 = intervals[`${Number(int) + 1}`]; + if (interval1 != null && interval2 != null) { + const date1 = moment(Number(interval1.timestamp)); + const date2 = moment(Number(interval2.timestamp)); + // This'll be in ms + const diff = date1.diff(date2); + + return diff === 3600000; + } - expect(diff).toEqual(3600000); + return true; + }); + + expect(allAre1HourApart).toBeTruthy(); }); test('returns error if range is anything other than "h" or "d"', () => { @@ -604,17 +620,100 @@ describe('eql/helpers', () => { }); }); - describe('getSequenceAggs', () => { - test('it aggregates events by sequences', () => { - const mockResponse = getMockSequenceResponse(); - const sequenceAggs = getSequenceAggs(mockResponse, jest.fn() as inputsModel.Refetch); + describe('formatInspect', () => { + test('it should return "dsl" with response params and index info', () => { + const { dsl } = formatInspect(getMockEqlResponse(), ['foo-*']); + + expect(JSON.parse(dsl[0])).toEqual({ + body: { + filter: { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gte: '2020-10-07T00:46:12.414Z', + lte: '2020-10-07T01:46:12.414Z', + }, + }, + }, + }, + index: ['foo-*'], + method: 'GET', + path: '/_eql/search/', + querystring: 'some query string', + }); + }); + + test('it should return "response"', () => { + const mockResponse = getMockEqlResponse(); + const { response } = formatInspect(mockResponse, ['foo-*']); + + expect(JSON.parse(response[0])).toEqual(mockResponse.rawResponse.body); + }); + }); - expect(sequenceAggs.data).toEqual([ - { g: 'Seq. 1', x: '2020-10-04T15:16:54.368707900Z', y: 1 }, - { g: 'Seq. 1', x: '2020-10-04T15:50:54.368707900Z', y: 1 }, - { g: 'Seq. 2', x: '2020-10-04T15:06:54.368707900Z', y: 1 }, - { g: 'Seq. 2', x: '2020-10-04T15:15:54.368707900Z', y: 1 }, + describe('getEventsToBucket', () => { + test('returns events for non-sequence queries', () => { + const events = getEventsToBucket(false, getMockEqlResponse()); + + expect(events).toEqual([ + { _id: '1', _index: 'index', _source: { '@timestamp': '2020-10-04T15:16:54.368707900Z' } }, + { _id: '2', _index: 'index', _source: { '@timestamp': '2020-10-04T15:50:54.368707900Z' } }, + { _id: '3', _index: 'index', _source: { '@timestamp': '2020-10-04T15:06:54.368707900Z' } }, + { _id: '4', _index: 'index', _source: { '@timestamp': '2020-10-04T15:15:54.368707900Z' } }, ]); }); + + test('returns empty array if no hits', () => { + const resp = getMockEqlResponse(); + const mockResponse: EqlSearchStrategyResponse> = { + ...resp, + rawResponse: { + ...resp.rawResponse, + body: { + ...resp.rawResponse.body, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + const events = getEventsToBucket(false, mockResponse); + + expect(events).toEqual([]); + }); + + test('returns events for sequence queries', () => { + const events = getEventsToBucket(true, getMockEqlSequenceResponse()); + + expect(events).toEqual([ + { _id: '2', _index: 'index', _source: { '@timestamp': '2020-10-04T15:50:54.368707900Z' } }, + { _id: '4', _index: 'index', _source: { '@timestamp': '2020-10-04T15:15:54.368707900Z' } }, + ]); + }); + + test('returns empty array if no sequences', () => { + const resp = getMockEqlSequenceResponse(); + const mockResponse: EqlSearchStrategyResponse> = { + ...resp, + rawResponse: { + ...resp.rawResponse, + body: { + ...resp.rawResponse.body, + hits: { + total: { + value: 0, + relation: '', + }, + }, + }, + }, + }; + const events = getEventsToBucket(true, mockResponse); + + expect(events).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts index 4b5986d966df3e..d1a29987c8ced4 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/helpers.ts @@ -10,55 +10,105 @@ import { inputsModel } from '../../../common/store'; import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common'; import { InspectResponse } from '../../../types'; import { EqlPreviewResponse, Source } from './types'; -import { EqlSearchResponse } from '../../../../common/detection_engine/types'; +import { BaseHit, EqlSearchResponse } from '../../../../common/detection_engine/types'; type EqlAggBuckets = Record; export const EQL_QUERY_EVENT_SIZE = 100; -// Calculates which 2 min bucket segment, event should be -// sorted into +/** + * Calculates which 2 min bucket segment, event should be sorted into + * @param eventTimestamp The event to be bucketed timestamp + * @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred + */ export const calculateBucketForHour = (eventTimestamp: number, relativeNow: number): number => { - const diff: number = relativeNow - eventTimestamp; - const minutes: number = Math.floor(diff / 60000); + const diff = Math.abs(relativeNow - eventTimestamp); + const minutes = Math.floor(diff / 60000); return Math.ceil(minutes / 2) * 2; }; -// Calculates which 1 hour bucket segment, event should be -// sorted into +/** + * Calculates which 1 hour bucket segment, event should be sorted into + * @param eventTimestamp The event to be bucketed timestamp + * @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred + */ export const calculateBucketForDay = (eventTimestamp: number, relativeNow: number): number => { - const diff: number = relativeNow - eventTimestamp; - const minutes: number = Math.floor(diff / 60000); + const diff = Math.abs(relativeNow - eventTimestamp); + const minutes = Math.floor(diff / 60000); return Math.ceil(minutes / 60); }; +/** + * Formats the response for the UI inspect modal + * @param response The query search response + * @param indices The indices the query searched + * TODO: Update eql search strategy to return index in it's meta + * params info, currently not being returned, but expected for + * inspect modal display + */ export const formatInspect = ( - response: EqlSearchStrategyResponse> + response: EqlSearchStrategyResponse>, + indices: string[] ): InspectResponse => { const body = response.rawResponse.meta.request.params.body; - const bodyParse = typeof body === 'string' ? JSON.parse(body) : body; + const bodyParse: Record | undefined = + typeof body === 'string' ? JSON.parse(body) : body; return { dsl: [ - JSON.stringify({ ...response.rawResponse.meta.request.params, body: bodyParse }, null, 2), + JSON.stringify( + { ...response.rawResponse.meta.request.params, index: indices, body: bodyParse }, + null, + 2 + ), ], response: [JSON.stringify(response.rawResponse.body, null, 2)], }; }; -// NOTE: Eql does not support aggregations, this is an in-memory -// hand-spun aggregation for the events to give the user a visual -// representation of their query results +/** + * Gets the events out of the response based on type of query + * @param isSequence Is the eql query a sequence query + * @param response The query search response + */ +export const getEventsToBucket = ( + isSequence: boolean, + response: EqlSearchStrategyResponse> +): Array> => { + const hits = response.rawResponse.body.hits ?? []; + if (isSequence) { + return ( + hits.sequences?.map((seq) => { + return seq.events[seq.events.length - 1]; + }) ?? [] + ); + } else { + return hits.events ?? []; + } +}; + +/** + * Eql does not support aggregations, this is an in-memory + * hand-spun aggregation for the events to give the user a visual + * representation of their query results + * @param response The query search response + * @param range User chosen timeframe (last hour, day) + * @param to Based on range chosen + * @param refetch Callback used in inspect button, ref just passed through + * @param indices Indices searched by query + * @param isSequence Is the eql query a sequence query + */ export const getEqlAggsData = ( response: EqlSearchStrategyResponse>, range: Unit, to: string, - refetch: inputsModel.Refetch + refetch: inputsModel.Refetch, + indices: string[], + isSequence: boolean ): EqlPreviewResponse => { - const { dsl, response: inspectResponse } = formatInspect(response); - // The upper bound of the timestamps + const { dsl, response: inspectResponse } = formatInspect(response, indices); const relativeNow = Date.parse(to); const accumulator = getInterval(range, relativeNow); - const events = response.rawResponse.body.hits.events ?? []; + const events = getEventsToBucket(isSequence, response); const totalCount = response.rawResponse.body.hits.total.value; const buckets = events.reduce((acc, hit) => { @@ -94,12 +144,23 @@ export const getEqlAggsData = ( }; }; -export const createIntervalArray = (start: number, end: number, multiplier: number) => { +/** + * Helper method to create an array to be used for calculating bucket intervals + * @param start + * @param end + * @param multiplier + */ +export const createIntervalArray = (start: number, end: number, multiplier: number): number[] => { return Array(end - start + 1) .fill(0) .map((_, idx) => start + idx * multiplier); }; +/** + * Helper method to create an array to be used for calculating bucket intervals + * @param range User chosen timeframe (last hour, day) + * @param relativeNow Based on range chosen + */ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => { switch (range) { case 'h': @@ -117,38 +178,6 @@ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => }; }, {}); default: - throw new Error('Invalid time range selected'); + throw new RangeError('Invalid time range selected. Must be "Last hour" or "Last day".'); } }; - -export const getSequenceAggs = ( - response: EqlSearchStrategyResponse>, - refetch: inputsModel.Refetch -): EqlPreviewResponse => { - const { dsl, response: inspectResponse } = formatInspect(response); - const sequences = response.rawResponse.body.hits.sequences ?? []; - const totalCount = response.rawResponse.body.hits.total.value; - - const data = sequences.map((sequence, i) => { - return sequence.events.map((seqEvent) => { - if (seqEvent._source['@timestamp'] == null) { - return {}; - } - return { - x: seqEvent._source['@timestamp'], - y: 1, - g: `Seq. ${i + 1}`, - }; - }); - }); - - return { - data: data.flat(), - totalCount, - inspect: { - dsl, - response: inspectResponse, - }, - refetch, - }; -}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts index ae7a263cc7012c..663791a00940f3 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.test.ts @@ -14,7 +14,7 @@ import { Source } from './types'; import { EqlSearchResponse } from '../../../../common/detection_engine/types'; import { useKibana } from '../../../common/lib/kibana'; import { useEqlPreview } from '.'; -import { getMockResponse } from './helpers.test'; +import { getMockEqlResponse } from './eql_search_response.mock'; jest.mock('../../../common/lib/kibana'); @@ -32,7 +32,9 @@ describe('useEqlPreview', () => { useKibana().services.notifications.toasts.addWarning = jest.fn(); - (useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ); }); it('should initiate hook', async () => { @@ -96,7 +98,7 @@ describe('useEqlPreview', () => { it('should not resolve values after search is invoked if component unmounted', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of(getMockResponse()).pipe(delay(5000)) + of(getMockEqlResponse()).pipe(delay(5000)) ); const { result, waitForNextUpdate, unmount } = renderHook(() => useEqlPreview()); @@ -117,9 +119,11 @@ describe('useEqlPreview', () => { it('should not resolve new values on search if response is error response', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( - of({ isRunning: false, isPartial: true } as EqlSearchStrategyResponse< - EqlSearchResponse - >) + of>>({ + ...getMockEqlResponse(), + isRunning: false, + isPartial: true, + }) ); const { result, waitForNextUpdate } = renderHook(() => useEqlPreview()); @@ -136,6 +140,30 @@ describe('useEqlPreview', () => { }); }); + // TODO: Determine why eql search strategy returns null for meta.params.body + // in complete responses, but not in partial responses + it('should update inspect information on partial response', async () => { + const mockResponse = getMockEqlResponse(); + await act(async () => { + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of>>({ + isRunning: true, + isPartial: true, + rawResponse: mockResponse.rawResponse, + }) + ); + + const { result, waitForNextUpdate } = renderHook(() => useEqlPreview()); + + await waitForNextUpdate(); + + result.current[1](params); + + expect(result.current[2].inspect.dsl.length).toEqual(1); + expect(result.current[2].inspect.response.length).toEqual(1); + }); + }); + it('should add danger toast if search throws', async () => { await act(async () => { (useKibana().services.data.search.search as jest.Mock).mockReturnValue( diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts index 1bfaecdf089bec..93236381753bf0 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/eql/use_eql_preview.ts @@ -14,12 +14,13 @@ import { AbortError, isCompleteResponse, isErrorResponse, + isPartialResponse, } from '../../../../../../../src/plugins/data/common'; import { EqlSearchStrategyRequest, EqlSearchStrategyResponse, } from '../../../../../data_enhanced/common'; -import { getEqlAggsData, getSequenceAggs } from './helpers'; +import { formatInspect, getEqlAggsData } from './helpers'; import { EqlPreviewResponse, EqlPreviewRequest, Source } from './types'; import { hasEqlSequenceQuery } from '../../../../common/detection_engine/utils'; import { EqlSearchResponse } from '../../../../common/detection_engine/types'; @@ -106,13 +107,37 @@ export const useEqlPreview = (): [ if (isCompleteResponse(res)) { if (!didCancel.current) { setLoading(false); - if (hasEqlSequenceQuery(query)) { - setResponse(getSequenceAggs(res, refetch.current)); - } else { - setResponse(getEqlAggsData(res, interval, to, refetch.current)); - } + + setResponse((prev) => { + const { inspect, ...rest } = getEqlAggsData( + res, + interval, + to, + refetch.current, + index, + hasEqlSequenceQuery(query) + ); + const inspectDsl = prev.inspect.dsl[0] ? prev.inspect.dsl : inspect.dsl; + const inspectResp = prev.inspect.response[0] + ? prev.inspect.response + : inspect.response; + + return { + ...prev, + ...rest, + inspect: { + dsl: inspectDsl, + response: inspectResp, + }, + }; + }); } + unsubscribeStream.current.next(); + } else if (isPartialResponse(res)) { + // TODO: Eql search strategy partial responses return a value under meta.params.body + // but the final/complete response does not, that's why the inspect values are set here + setResponse((prev) => ({ ...prev, inspect: formatInspect(res, index) })); } else if (isErrorResponse(res)) { setLoading(false); notifications.toasts.addWarning(i18n.EQL_PREVIEW_FETCH_FAILURE); diff --git a/x-pack/plugins/security_solution/public/common/lib/telemetry/index.ts b/x-pack/plugins/security_solution/public/common/lib/telemetry/index.ts index e79ef0d1282250..863097a5cd2ee7 100644 --- a/x-pack/plugins/security_solution/public/common/lib/telemetry/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/telemetry/index.ts @@ -25,7 +25,15 @@ export const track: TrackFn = (type, event, count) => { } }; -export const initTelemetry = (usageCollection: SetupPlugins['usageCollection'], appId: string) => { +export const initTelemetry = ( + { + usageCollection, + telemetryManagementSection, + }: Pick, + appId: string +) => { + telemetryManagementSection?.toggleSecuritySolutionExample(true); + _track = usageCollection?.reportUiStats?.bind(null, appId) ?? noop; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index d66d37a020040b..a50167433ccad2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -54,6 +54,7 @@ interface OwnProps { hasIndexWrite: boolean; from: string; loading: boolean; + onRuleChange?: () => void; showBuildingBlockAlerts: boolean; onShowBuildingBlockAlertsChanged: (showBuildingBlockAlerts: boolean) => void; to: string; @@ -75,6 +76,7 @@ export const AlertsTableComponent: React.FC = ({ isSelectAllChecked, loading, loadingEventIds, + onRuleChange, selectedEventIds, setEventsDeleted, setEventsLoading, @@ -330,6 +332,7 @@ export const AlertsTableComponent: React.FC = ({ end={to} headerFilterGroup={headerFilterGroup} id={timelineId} + onRuleChange={onRuleChange} scopeId={SourcererScopeName.detections} start={from} utilityBar={utilityBarCallback} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 1a0b35620c9c98..0315d513ee2600 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -29,7 +29,7 @@ import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter import { updateAlertStatusAction } from '../actions'; import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; import { Ecs } from '../../../../../common/ecs'; -import { AddExceptionModal as AddExceptionModalComponent } from '../../../../common/components/exceptions/add_exception_modal'; +import { AddExceptionModal } from '../../../../common/components/exceptions/add_exception_modal'; import * as i18nCommon from '../../../../common/translations'; import * as i18n from '../translations'; import { @@ -45,6 +45,7 @@ interface AlertContextMenuProps { disabled: boolean; ecsRowData: Ecs; refetch: inputsModel.Refetch; + onRuleChange?: () => void; timelineId: string; } @@ -52,6 +53,7 @@ const AlertContextMenuComponent: React.FC = ({ disabled, ecsRowData, refetch, + onRuleChange, timelineId, }) => { const dispatch = useDispatch(); @@ -376,7 +378,7 @@ const AlertContextMenuComponent: React.FC = ({ {exceptionModalType != null && ruleId != null && ecsRowData != null && ( - = ({ onCancel={onAddExceptionCancel} onConfirm={onAddExceptionConfirm} alertStatus={alertStatus} + onRuleChange={onRuleChange} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx index 01d95fa80ba596..3dc3213d653146 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.test.tsx @@ -52,7 +52,7 @@ describe('PreviewCustomQueryHistogram', () => { expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); + ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); }); test('it configures data and subtitle', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx index 787e8dab393cac..77b6fbb938e208 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/custom_histogram.tsx @@ -54,7 +54,7 @@ export const PreviewCustomQueryHistogram = ({ const subtitle = useMemo( (): string => - isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), + isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), [isLoading, totalCount] ); @@ -67,7 +67,7 @@ export const PreviewCustomQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER} isLoading={isLoading} data-test-subj="queryPreviewCustomHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx index 16e71485de9a6c..3e7807f423be92 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.test.tsx @@ -39,7 +39,6 @@ describe('PreviewEqlQueryHistogram', () => { { expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy(); expect( wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').at(0).prop('subtitle') - ).toEqual(i18n.PREVIEW_SUBTITLE_LOADING); + ).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING); }); test('it configures data and subtitle', () => { @@ -63,7 +62,6 @@ describe('PreviewEqlQueryHistogram', () => { { { refetch: mockRefetch, }); }); + + test('it displays histogram', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy(); + expect( + wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists() + ).toBeFalsy(); + expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx index 8f2774a1342b6a..ed1fd5b7367d4f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/eql_histogram.tsx @@ -15,7 +15,6 @@ import { } from '../../../../common/components/charts/common'; import { InspectQuery } from '../../../../common/store/inputs/model'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { hasEqlSequenceQuery } from '../../../../../common/detection_engine/utils'; import { inputsModel } from '../../../../common/store'; import { PreviewHistogram } from './histogram'; @@ -26,7 +25,6 @@ interface PreviewEqlQueryHistogramProps { from: string; totalCount: number; isLoading: boolean; - query: string; data: ChartData[]; inspect: InspectQuery; refetch: inputsModel.Refetch; @@ -36,7 +34,6 @@ export const PreviewEqlQueryHistogram = ({ from, to, totalCount, - query, data, inspect, refetch, @@ -50,14 +47,11 @@ export const PreviewEqlQueryHistogram = ({ } }, [setQuery, inspect, isInitializing, refetch]); - const barConfig = useMemo( - (): ChartSeriesConfigs => getHistogramConfig(to, from, hasEqlSequenceQuery(query)), - [from, to, query] - ); + const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]); const subtitle = useMemo( (): string => - isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), + isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount), [isLoading, totalCount] ); @@ -70,7 +64,7 @@ export const PreviewEqlQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER_EQL} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER_EQL} isLoading={isLoading} data-test-subj="queryPreviewEqlHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts index ed8994a4c44fd2..89d017f4721f6c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/helpers.ts @@ -16,13 +16,13 @@ import { FieldValueQueryBar } from '../query_bar'; import { ESQuery } from '../../../../../common/typed_json'; import { Filter } from '../../../../../../../../src/plugins/data/common/es_query'; -export const HITS_THRESHOLD: Record = { - h: 1, - d: 24, - M: 730, -}; - -export const isNoisy = (hits: number, timeframe: Unit) => { +/** + * Determines whether or not to display noise warning. + * Is considered noisy if alerts/hour rate > 1 + * @param hits Total query search hits + * @param timeframe Range selected by user (last hour, day...) + */ +export const isNoisy = (hits: number, timeframe: Unit): boolean => { if (timeframe === 'h') { return hits > 1; } else if (timeframe === 'd') { @@ -34,6 +34,12 @@ export const isNoisy = (hits: number, timeframe: Unit) => { return false; }; +/** + * Determines what timerange options to show. + * Eql sequence queries tend to be slower, so decided + * not to include the last month option. + * @param ruleType + */ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { if (ruleType === 'eql') { return [ @@ -49,6 +55,13 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => { } }; +/** + * Quick little helper to extract the query info from the + * queryBar object. + * @param queryBar Object containing all query info + * @param index Indices searched + * @param ruleType + */ export const getInfoFromQueryBar = ( queryBar: FieldValueQueryBar, index: string[], @@ -88,10 +101,15 @@ export const getInfoFromQueryBar = ( } }; +/** + * Config passed into elastic-charts settings. + * @param to + * @param from + */ export const getHistogramConfig = ( to: string, from: string, - showLegend: boolean = false + showLegend = false ): ChartSeriesConfigs => { return { series: { @@ -131,7 +149,11 @@ export const getHistogramConfig = ( }; }; -export const getThresholdHistogramConfig = (height: number | undefined): ChartSeriesConfigs => { +/** + * Threshold histogram is displayed a bit differently, + * x-axis is not time based, but ordinal. + */ +export const getThresholdHistogramConfig = (): ChartSeriesConfigs => { return { series: { xScaleType: ScaleType.Ordinal, @@ -165,6 +187,6 @@ export const getThresholdHistogramConfig = (height: number | undefined): ChartSe }, }, }, - customHeight: height ?? 200, + customHeight: 200, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx index 87436ad1e6d2bd..26891dae1752a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.test.tsx @@ -13,9 +13,13 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { TestProviders } from '../../../../common/mock'; import { useKibana } from '../../../../common/lib/kibana'; import { PreviewQuery } from './'; -import { getMockResponse } from '../../../../common/hooks/eql/helpers.test'; +import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock'; +import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram'; +import { useEqlPreview } from '../../../../common/hooks/eql/'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/containers/matrix_histogram'); +jest.mock('../../../../common/hooks/eql/'); describe('PreviewQuery', () => { beforeEach(() => { @@ -23,7 +27,33 @@ describe('PreviewQuery', () => { useKibana().services.notifications.toasts.addWarning = jest.fn(); - (useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse())); + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 1, + refetch: jest.fn(), + data: [], + buckets: [], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + (useEqlPreview as jest.Mock).mockReturnValue([ + false, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + { + inspect: { dsl: [], response: [] }, + totalCount: 1, + refetch: jest.fn(), + data: [], + buckets: [], + }, + ]); }); afterEach(() => { @@ -121,6 +151,42 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 2, + refetch: jest.fn(), + data: [], + buckets: [], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders query histogram when rule type is saved_query and preview button clicked', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -175,6 +241,42 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeTruthy(); }); + test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useEqlPreview as jest.Mock).mockReturnValue([ + false, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + { + inspect: { dsl: [], response: [] }, + totalCount: 2, + refetch: jest.fn(), + data: [], + buckets: [], + }, + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -192,16 +294,70 @@ describe('PreviewQuery', () => { ); + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 500, + refetch: jest.fn(), + data: [], + buckets: [{ key: 'siem-kibana', doc_count: 500 }], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls; expect(mockCalls.length).toEqual(1); + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + (useMatrixHistogram as jest.Mock).mockReturnValue([ + false, + { + inspect: { dsl: [], response: [] }, + totalCount: 500, + refetch: jest.fn(), + data: [], + buckets: [ + { key: 'siem-kibana', doc_count: 200 }, + { key: 'siem-windows', doc_count: 300 }, + ], + }, + (useKibana().services.data.search.search as jest.Mock).mockReturnValue( + of(getMockEqlResponse()) + ), + ]); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy(); + }); + test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -255,4 +411,33 @@ describe('PreviewQuery', () => { expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeFalsy(); expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy(); }); + + test('it hides histogram when timeframe changes', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + + + ); + + wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click'); + + expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeTruthy(); + + wrapper + .find('[data-test-subj="queryPreviewTimeframeSelect"] select') + .at(0) + .simulate('change', { target: { value: 'd' } }); + + expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx index f1cb8e3ba9fdbe..6669ea6d979692 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useCallback, useEffect, useReducer } from 'react'; +import React, { Fragment, useCallback, useEffect, useReducer, useRef } from 'react'; import { Unit } from '@elastic/datemath'; import styled from 'styled-components'; import { @@ -110,29 +110,31 @@ export const PreviewQuery = ({ { inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets }, startNonEql, ] = useMatrixHistogram({ - errorMessage: i18n.PREVIEW_QUERY_ERROR, + errorMessage: i18n.QUERY_PREVIEW_ERROR, endDate: fromTime, startDate: toTime, filterQuery: queryFilter, indexNames: index, histogramType: MatrixHistogramType.events, stackByField: 'event.category', - threshold, + threshold: ruleType === 'threshold' ? threshold : undefined, skip: true, }); const setQueryInfo = useCallback( - (queryBar: FieldValueQueryBar | undefined): void => { + (queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => { dispatch({ type: 'setQueryInfo', queryBar, - index, - ruleType, + index: indices, + ruleType: type, }); }, - [dispatch, index, ruleType] + [dispatch] ); + const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo)); + const setTimeframeSelect = useCallback( (selection: Unit): void => { dispatch({ @@ -190,11 +192,9 @@ export const PreviewQuery = ({ [dispatch] ); - useEffect((): void => { - const debounced = debounce(1000, setQueryInfo); - - debounced(query); - }, [setQueryInfo, query]); + useEffect(() => { + debouncedSetQueryInfo.current(query, index, ruleType); + }, [index, query, ruleType]); useEffect((): void => { setThresholdValues(threshold, ruleType); @@ -205,12 +205,32 @@ export const PreviewQuery = ({ }, [ruleType, setRuleTypeChange]); useEffect((): void => { - const totalHits = ruleType === 'eql' ? eqlQueryTotal : matrixHistTotal; - - if (isNoisy(totalHits, timeframe)) { - setNoiseWarning(); + switch (ruleType) { + case 'eql': + if (isNoisy(eqlQueryTotal, timeframe)) { + setNoiseWarning(); + } + break; + case 'threshold': + const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal; + if (isNoisy(totalHits, timeframe)) { + setNoiseWarning(); + } + break; + default: + if (isNoisy(matrixHistTotal, timeframe)) { + setNoiseWarning(); + } } - }, [timeframe, matrixHistTotal, eqlQueryTotal, ruleType, setNoiseWarning]); + }, [ + timeframe, + matrixHistTotal, + eqlQueryTotal, + ruleType, + setNoiseWarning, + thresholdFieldExists, + buckets.length, + ]); const handlePreviewEqlQuery = useCallback( (to: string, from: string): void => { @@ -263,8 +283,9 @@ export const PreviewQuery = ({ options={timeframeOptions} value={timeframe} onChange={handleSelectPreviewTimeframe} - aria-label={i18n.PREVIEW_SELECT_ARIA} + aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA} disabled={isDisabled} + data-test-subj="queryPreviewTimeframeSelect" /> @@ -276,7 +297,7 @@ export const PreviewQuery = ({ onClick={handlePreviewClicked} data-test-subj="queryPreviewButton" > - {i18n.PREVIEW_LABEL} + {i18n.QUERY_PREVIEW_BUTTON} @@ -307,7 +328,6 @@ export const PreviewQuery = ({ { expect(update).toEqual(initialState); }); - test('should reset showHistogram and warnings if queryBar undefined', () => { + test('should reset showHistogram if queryBar undefined', () => { const update = reducer( - { ...initialState, showHistogram: true, warnings: ['uh oh'] }, + { ...initialState, showHistogram: true }, { type: 'setQueryInfo', queryBar: undefined, @@ -44,11 +44,10 @@ describe('queryPreviewReducer', () => { } ); - expect(update.warnings).toEqual([]); expect(update.showHistogram).toBeFalsy(); }); - test('should reset showHistogram and warnings if queryBar defined', () => { + test('should reset showHistogram if queryBar defined', () => { const update = reducer( { ...initialState, showHistogram: true, warnings: ['uh oh'] }, { @@ -62,7 +61,6 @@ describe('queryPreviewReducer', () => { } ); - expect(update.warnings).toEqual([]); expect(update.showHistogram).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts index 76047a0af5c541..ba27098a8c350f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/reducer.ts @@ -82,13 +82,11 @@ export const queryPreviewReducer = () => (state: State, action: Action): State = filters, queryFilter, showHistogram: false, - warnings: [], }; } return { ...state, - warnings: [], showHistogram: false, }; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx index 1021c5b8ddcb70..a102c567a98e88 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/threshold_histogram.tsx @@ -56,12 +56,12 @@ export const PreviewThresholdQueryHistogram = ({ }; }, [buckets]); - const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(200), []); + const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []); const subtitle = useMemo( (): string => isLoading - ? i18n.PREVIEW_SUBTITLE_LOADING + ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount), [isLoading, totalCount] ); @@ -73,7 +73,7 @@ export const PreviewThresholdQueryHistogram = ({ barConfig={barConfig} title={i18n.QUERY_GRAPH_HITS_TITLE} subtitle={subtitle} - disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER} + disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER} isLoading={isLoading} data-test-subj="thresholdQueryPreviewHistogram" /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts index 7ae75c51dcf5a1..0d080113aeae83 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/query_preview/translations.ts @@ -6,14 +6,14 @@ import { i18n } from '@kbn/i18n'; -export const PREVIEW_LABEL = i18n.translate( - 'xpack.securitySolution.stepDefineRule.previewQueryLabel', +export const QUERY_PREVIEW_BUTTON = i18n.translate( + 'xpack.securitySolution.stepDefineRule.previewQueryButton', { defaultMessage: 'Preview results', } ); -export const PREVIEW_SELECT_ARIA = i18n.translate( +export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate( 'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel', { defaultMessage: 'Query preview timeframe select', @@ -85,14 +85,14 @@ export const QUERY_PREVIEW_NO_HITS = i18n.translate( } ); -export const PREVIEW_QUERY_ERROR = i18n.translate( +export const QUERY_PREVIEW_ERROR = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewError', { defaultMessage: 'Error fetching preview', } ); -export const PREVIEW_QUERY_DISCLAIMER = i18n.translate( +export const QUERY_PREVIEW_DISCLAIMER = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimer', { defaultMessage: @@ -100,7 +100,7 @@ export const PREVIEW_QUERY_DISCLAIMER = i18n.translate( } ); -export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate( +export const QUERY_PREVIEW_DISCLAIMER_EQL = i18n.translate( 'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimerEql', { defaultMessage: @@ -108,26 +108,24 @@ export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate( } ); -export const PREVIEW_WARNING_CAP_HIT = (cap: number) => - i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning', - { - values: { cap }, - defaultMessage: - 'Hit query cap size of {cap}. This query could produce more hits than the {cap} shown.', - } - ); +export const QUERY_PREVIEW_SUBTITLE_LOADING = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading', + { + defaultMessage: '...loading', + } +); -export const PREVIEW_WARNING_TIMESTAMP = i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning', +export const QUERY_PREVIEW_EQL_SEQUENCE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceTitle', { - defaultMessage: 'Unable to find "@timestamp" field on events.', + defaultMessage: 'No histogram available', } ); -export const PREVIEW_SUBTITLE_LOADING = i18n.translate( - 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading', +export const QUERY_PREVIEW_EQL_SEQUENCE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceDescription', { - defaultMessage: '...loading', + defaultMessage: + 'No histogram is available at this time for EQL sequence queries. You can use the inspect in the top right corner to view query details.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index fc03e07442f9e8..2479a260872be7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -87,10 +87,10 @@ const StepAboutRuleComponent: FC = ({ schema, }); const { getFields, getFormData, submit } = form; - const [{ severity: formSeverity }] = (useFormData({ + const [{ severity: formSeverity }] = useFormData({ form, watch: ['severity'], - }) as unknown) as [Partial]; + }); useEffect(() => { const formSeverityValue = formSeverity?.value; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx index 8e398e6236510a..757319e7aa1ae9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.test.tsx @@ -154,7 +154,9 @@ describe('StepAboutRuleToggleDetails', () => { .simulate('change', { target: { value: 'notes' } }); expect(wrapper.find('EuiButtonGroup[idSelected="notes"]').exists()).toBeTruthy(); - expect(wrapper.find('Markdown h1').text()).toEqual('this is some markdown documentation'); + expect(wrapper.find('.euiMarkdownFormat').text()).toEqual( + 'this is some markdown documentation' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx index 8604a5293a7107..52e9dc7e44ff71 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule_details/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; import { HeaderSection } from '../../../../common/components/header_section'; -import { Markdown } from '../../../../common/components/markdown'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; import { AboutStepRule, AboutStepRuleDetails } from '../../../pages/detection_engine/rules/types'; import * as i18n from './translations'; import { StepAboutRule } from '../step_about_rule'; @@ -136,7 +136,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ maxHeight={aboutPanelHeight} className="eui-yScrollWithShadows" > - + {stepDataDetails.note} )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 27d69c68870114..8a5966c71aa288 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react'; import styled from 'styled-components'; // Prefer importing entire lodash library, e.g. import { get } from "lodash" // eslint-disable-next-line no-restricted-imports @@ -53,7 +53,7 @@ import { import { EqlQueryBar } from '../eql_query_bar'; import { ThreatMatchInput } from '../threatmatch_input'; import { useFetchIndex } from '../../../../common/containers/source'; -import { PreviewQuery } from '../query_preview'; +import { PreviewQuery, Threshold } from '../query_preview'; const CommonUseField = getUseField({ component: Field }); @@ -141,17 +141,15 @@ const StepDefineRuleComponent: FC = ({ 'threshold.value': formThresholdValue, 'threshold.field': formThresholdField, }, - ] = (useFormData({ + ] = useFormData< + DefineStepRule & { + 'threshold.value': number | undefined; + 'threshold.field': string[] | undefined; + } + >({ form, watch: ['index', 'ruleType', 'queryBar', 'threshold.value', 'threshold.field', 'threatIndex'], - }) as unknown) as [ - Partial< - DefineStepRule & { - 'threshold.value': number | undefined; - 'threshold.field': string[] | undefined; - } - > - ]; + }); const [isQueryBarValid, setIsQueryBarValid] = useState(false); const index = formIndex || initialState.index; const threatIndex = formThreatIndex || initialState.threatIndex; @@ -212,6 +210,12 @@ const StepDefineRuleComponent: FC = ({ setOpenTimelineSearch(false); }, []); + const thresholdFormValue = useMemo((): Threshold | undefined => { + return formThresholdValue != null && formThresholdField != null + ? { value: formThresholdValue, field: formThresholdField[0] } + : undefined; + }, [formThresholdField, formThresholdValue]); + const ThresholdInputChildren = useCallback( ({ thresholdField, thresholdValue }) => ( = ({ index={index} query={formQuery} isDisabled={queryBarQuery.trim() === '' || !isQueryBarValid || index.length === 0} - threshold={ - formThresholdValue != null && formThresholdField != null - ? { value: formThresholdValue, field: formThresholdField[0] } - : undefined - } + threshold={thresholdFormValue} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 349a79d4e40f9c..dd1d92e7e72a3a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -93,10 +93,10 @@ const StepRuleActionsComponent: FC = ({ schema, }); const { getFields, getFormData, submit } = form; - const [{ throttle: formThrottle }] = (useFormData({ + const [{ throttle: formThrottle }] = useFormData({ form, watch: ['throttle'], - }) as unknown) as [Partial]; + }); const throttle = formThrottle || initialState.throttle; const handleSubmit = useCallback( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index b4eb40d0818998..54aae5c41bd5f2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -79,7 +79,7 @@ import { ExceptionsViewer } from '../../../../../common/components/exceptions/vi import { DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; import { useFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; -import { ExceptionListTypeEnum, ExceptionIdentifiers } from '../../../../../shared_imports'; +import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports'; import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; @@ -354,12 +354,12 @@ export const RuleDetailsPageComponent: FC = ({ const { indicesExist, indexPattern } = useSourcererScope(SourcererScopeName.detections); const exceptionLists = useMemo((): { - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; allowedExceptionListTypes: ExceptionListTypeEnum[]; } => { if (rule != null && rule.exceptions_list != null) { return rule.exceptions_list.reduce<{ - lists: ExceptionIdentifiers[]; + lists: ExceptionListIdentifiers[]; allowedExceptionListTypes: ExceptionListTypeEnum[]; }>( (acc, { id, list_id: listId, namespace_type: namespaceType, type }) => { @@ -542,6 +542,7 @@ export const RuleDetailsPageComponent: FC = ({ loading={loading} showBuildingBlockAlerts={showBuildingBlockAlerts} onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback} + onRuleChange={refreshRule} to={to} /> )} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index 0eedecef22170c..b76e0c8acf4c35 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -238,11 +238,23 @@ describe('policy details: ', () => { security: true, }, malware: { mode: 'prevent' }, + popup: { + malware: { + enabled: true, + message: '', + }, + }, logging: { file: 'info' }, }, mac: { events: { process: true, file: true, network: true }, malware: { mode: 'prevent' }, + popup: { + malware: { + enabled: true, + message: '', + }, + }, logging: { file: 'info' }, }, linux: { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index c236b2841fc858..953438526b87ed 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -105,10 +105,12 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel windows: { events: windows.events, malware: windows.malware, + popup: windows.popup, }, mac: { events: mac.events, malware: mac.malware, + popup: mac.popup, }, linux: { events: linux.events, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 1698f5bc3fd0c2..1da832fb081efc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -14,6 +14,7 @@ import { EuiSpacer, htmlIdGenerator, EuiCallOut, + EuiCheckbox, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -50,6 +51,11 @@ const ProtectionRadio = React.memo(({ id, label }: { id: ProtectionModes; label: const newPayload = clone(policyDetailsConfig); for (const os of OSes) { newPayload[os][protection].mode = id; + if (id === ProtectionModes.prevent) { + newPayload[os].popup[protection].enabled = true; + } else { + newPayload[os].popup[protection].enabled = false; + } } dispatch({ type: 'userChangedPolicyConfig', @@ -85,6 +91,8 @@ export const MalwareProtections = React.memo(() => { const dispatch = useDispatch(); // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; + const userNotificationSelected = + policyDetailsConfig && policyDetailsConfig.windows.popup.malware.enabled; const radios: Immutable { if (event.target.checked === false) { for (const os of OSes) { newPayload[os][protection].mode = ProtectionModes.off; + newPayload[os].popup[protection].enabled = event.target.checked; } } else { for (const os of OSes) { newPayload[os][protection].mode = ProtectionModes.prevent; + newPayload[os].popup[protection].enabled = event.target.checked; } } dispatch({ @@ -131,6 +141,22 @@ export const MalwareProtections = React.memo(() => { [dispatch, policyDetailsConfig] ); + const handleUserNotificationCheckbox = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = clone(policyDetailsConfig); + for (const os of OSes) { + newPayload[os].popup[protection].enabled = event.target.checked; + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [policyDetailsConfig, dispatch] + ); + const radioButtons = useMemo(() => { return ( <> @@ -154,9 +180,27 @@ export const MalwareProtections = React.memo(() => { ); })} + + +
+ +
+
+ ); - }, [radios]); + }, [radios, handleUserNotificationCheckbox, userNotificationSelected]); const protectionSwitch = useMemo(() => { return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index 399509466e5731..86cb203671ac2b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -344,22 +344,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -595,22 +596,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -846,22 +848,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -1097,22 +1100,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -1348,22 +1352,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -1599,22 +1604,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -1850,22 +1856,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -2101,22 +2108,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -2352,22 +2360,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -2603,22 +2612,23 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
- + +
@@ -3144,22 +3154,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -3395,22 +3406,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -3646,22 +3658,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -3897,22 +3910,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -4148,22 +4162,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -4399,22 +4414,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -4650,22 +4666,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -4901,22 +4918,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -5152,22 +5170,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -5403,22 +5422,23 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
- + +
@@ -5902,22 +5922,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -6153,22 +6174,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -6404,22 +6426,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -6655,22 +6678,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -6906,22 +6930,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -7157,22 +7182,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -7408,22 +7434,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -7659,22 +7686,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -7910,22 +7938,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
@@ -8161,22 +8190,23 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
- + +
diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap index a56e71e8073cb1..841f9dc81bd8e7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_list/__snapshots__/index.test.tsx.snap @@ -996,22 +996,23 @@ exports[`TrustedAppsList renders correctly when item details expanded 1`] = `
- + +
diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx index fea605b13d0672..b898f1b3fd7ab3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useCallback, useMemo } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiButtonEmpty, + EuiButtonProps, + PropsForButton, EuiModal, EuiModalBody, EuiModalFooter, @@ -66,6 +68,21 @@ const getTranslations = (entry: Immutable | undefined) => ({ ), }); +const AutoFocusButton: FC> = memo((props) => { + const buttonRef = useRef(null); + const button = ; + + useEffect(() => { + if (buttonRef.current) { + buttonRef.current.focus(); + } + }, []); + + return button; +}); + +AutoFocusButton.displayName = 'AutoFocusButton'; + export const TrustedAppDeletionDialog = memo(() => { const dispatch = useDispatch>(); const isBusy = useTrustedAppsSelector(isDeletionInProgress); @@ -100,7 +117,7 @@ export const TrustedAppDeletionDialog = memo(() => { {translations.cancelButton} - { data-test-subj={CONFIRM_SUBJ} > {translations.confirmButton} - + diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx index 5867a9d859f04c..95f0fbb194ca62 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/recent_cases.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { Case } from '../../../cases/containers/types'; import { getCaseDetailsUrl } from '../../../common/components/link_to/redirect_to_case'; -import { Markdown } from '../../../common/components/markdown'; +import { MarkdownRenderer } from '../../../common/components/markdown_editor'; import { useFormatUrl } from '../../../common/components/link_to'; import { IconWithCount } from '../recent_timelines/counts'; import { LinkAnchor } from '../../../common/components/links'; @@ -52,7 +52,7 @@ const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { {c.description && c.description.length && ( - + {c.description} )} diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 29aa0b111b78a9..ea8c086c9bdc10 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -85,7 +85,13 @@ export class Plugin implements IPlugin, plugins: SetupPlugins): PluginSetup { - initTelemetry(plugins.usageCollection, APP_ID); + initTelemetry( + { + usageCollection: plugins.usageCollection, + telemetryManagementSection: plugins.telemetryManagementSection, + }, + APP_ID + ); if (plugins.home) { plugins.home.featureCatalogue.registerSolution({ diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index f60fefc4f6dfa2..a0484ca39c2b56 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -49,7 +49,7 @@ export { updateExceptionListItem, fetchExceptionListById, addExceptionList, - ExceptionIdentifiers, + ExceptionListIdentifiers, ExceptionList, Pagination, UseExceptionListSuccess, diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap index 718e7ce1d27a5a..53bc76bfeb8e86 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/index.test.tsx.snap @@ -11,13 +11,6 @@ exports[`AddNote renders correctly 1`] = ` noteInputHeight={200} updateNewNote={[MockFunction]} /> - - - diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap index 9bf2b5c65e8294..69e06bc7e0d1bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/__snapshots__/new_note.test.tsx.snap @@ -3,54 +3,13 @@ exports[`NewNote renders correctly 1`] = ` , - "id": "note", - "name": "Note", - } - } - tabs={ - Array [ - Object { - "content":