diff --git a/.backportrc.json b/.backportrc.json index 8f458343c51af..3f1d639e9a480 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -3,6 +3,7 @@ "targetBranchChoices": [ { "name": "master", "checked": true }, { "name": "7.x", "checked": true }, + "7.9", "7.8", "7.7", "7.6", @@ -26,7 +27,7 @@ "targetPRLabels": ["backport"], "branchLabelMapping": { "^v8.0.0$": "master", - "^v7.9.0$": "7.x", + "^v7.10.0$": "7.x", "^v(\\d+).(\\d+).\\d+$": "$1.$2" } } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f053c6da9c29b..2ad82ded6cb38 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -24,29 +24,20 @@ /src/plugins/vis_type_xy/ @elastic/kibana-app /src/plugins/visualize/ @elastic/kibana-app -# Core UI -# Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon -/src/plugins/home/public @elastic/kibana-core-ui -/src/plugins/home/server/*.ts @elastic/kibana-core-ui -/src/plugins/home/server/services/ @elastic/kibana-core-ui -# Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon -/src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/*.scss @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui - # App Architecture +/examples/bfetch_explorer/ @elastic/kibana-app-arch +/examples/dashboard_embeddable_examples/ @elastic/kibana-app-arch +/examples/demo_search/ @elastic/kibana-app-arch /examples/developer_examples/ @elastic/kibana-app-arch +/examples/embeddable_examples/ @elastic/kibana-app-arch +/examples/embeddable_explorer/ @elastic/kibana-app-arch +/examples/state_container_examples/ @elastic/kibana-app-arch +/examples/ui_actions_examples/ @elastic/kibana-app-arch +/examples/ui_actions_explorer/ @elastic/kibana-app-arch /examples/url_generators_examples/ @elastic/kibana-app-arch /examples/url_generators_explorer/ @elastic/kibana-app-arch -/packages/kbn-interpreter/ @elastic/kibana-app-arch /packages/elastic-datemath/ @elastic/kibana-app-arch -/src/legacy/core_plugins/embeddable_api/ @elastic/kibana-app-arch -/src/legacy/core_plugins/interpreter/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana_react/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana/public/management/ @elastic/kibana-app-arch -/src/legacy/core_plugins/kibana/server/routes/api/management/ @elastic/kibana-app-arch -/src/legacy/core_plugins/visualizations/ @elastic/kibana-app-arch -/src/legacy/server/index_patterns/ @elastic/kibana-app-arch +/packages/kbn-interpreter/ @elastic/kibana-app-arch /src/plugins/advanced_settings/ @elastic/kibana-app-arch /src/plugins/bfetch/ @elastic/kibana-app-arch /src/plugins/data/ @elastic/kibana-app-arch @@ -61,9 +52,10 @@ /src/plugins/share/ @elastic/kibana-app-arch /src/plugins/ui_actions/ @elastic/kibana-app-arch /src/plugins/visualizations/ @elastic/kibana-app-arch -/x-pack/plugins/advanced_ui_actions/ @elastic/kibana-app-arch +/x-pack/examples/ui_actions_enhanced_examples/ @elastic/kibana-app-arch /x-pack/plugins/data_enhanced/ @elastic/kibana-app-arch -/x-pack/plugins/drilldowns/ @elastic/kibana-app-arch +/x-pack/plugins/embeddable_enhanced/ @elastic/kibana-app-arch +/x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-arch # APM /x-pack/plugins/apm/ @elastic/apm-ui @@ -79,6 +71,16 @@ /x-pack/plugins/canvas/ @elastic/kibana-canvas /x-pack/test/functional/apps/canvas/ @elastic/kibana-canvas +# Core UI +# Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon +/src/plugins/home/public @elastic/kibana-core-ui +/src/plugins/home/server/*.ts @elastic/kibana-core-ui +/src/plugins/home/server/services/ @elastic/kibana-core-ui +# Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon +/src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui +/src/legacy/core_plugins/kibana/public/home/*.scss @elastic/kibana-core-ui +/src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui + # Observability UIs /x-pack/legacy/plugins/infra/ @elastic/logs-metrics-ui /x-pack/plugins/infra/ @elastic/logs-metrics-ui diff --git a/docs/user/alerting/action-types.asciidoc b/docs/user/alerting/action-types.asciidoc index e8dcf689df8e4..1743edb10f92b 100644 --- a/docs/user/alerting/action-types.asciidoc +++ b/docs/user/alerting/action-types.asciidoc @@ -23,6 +23,10 @@ a| <> | Add a message to a Kibana log. +a| <> + +| Push or update data to a new incident in ServiceNow. + a| <> | Send a message to a Slack channel or user. @@ -55,3 +59,4 @@ include::action-types/server-log.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::action-types/pre-configured-connectors.asciidoc[] +include::action-types/servicenow.asciidoc[] diff --git a/docs/user/alerting/action-types/servicenow.asciidoc b/docs/user/alerting/action-types/servicenow.asciidoc new file mode 100644 index 0000000000000..32f828aea2357 --- /dev/null +++ b/docs/user/alerting/action-types/servicenow.asciidoc @@ -0,0 +1,72 @@ +[role="xpack"] +[[servicenow-action-type]] +=== ServiceNow action + +The ServiceNow action type uses the https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI[V2 Table API] to create ServiceNow incidents. + +[float] +[[servicenow-connector-configuration]] +==== Connector configuration + +ServiceNow connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +URL:: ServiceNow instance URL. +Username:: Username for HTTP Basic authentication. +Password:: Password for HTTP Basic authentication. + +[float] +[[Preconfigured-servicenow-configuration]] +==== Preconfigured action type + +[source,text] +-- + my-servicenow: + name: preconfigured-servicenow-action-type + actionTypeId: .servicenow + config: + apiUrl: https://dev94428.service-now.com/ + secrets: + username: testuser + password: passwordkeystorevalue +-- + +`config` defines the action type specific to the configuration and contains the following properties: + +[cols="2*<"] +|=== + +| `apiUrl` +| An address that corresponds to *Sender*. + +|=== + +`secrets` defines sensitive information for the action type: + +[cols="2*<"] +|=== + +| `username` +| A string that corresponds to *User*. + +| `password` +| A string that corresponds to *Password*. Should be stored in the <>. + +|=== + +[[servicenow-action-configuration]] +==== Action configuration + +ServiceNow actions have the following configuration properties: + +Urgency:: The extent to which the incident resolution can delay. +Severity:: The severity of the incident. +Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. +Short description:: A short description of the incident, used for searching the contents of the knowledge base. +Description:: The details about the incident. +Additional comments:: Additional information for the client, such as how to troubleshoot the issue. + +[[configuring-servicenow]] +==== Configuring and testing ServiceNow + +ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents. diff --git a/docs/user/ml/images/ml-annotations-list.jpg b/docs/user/ml/images/ml-annotations-list.jpg deleted file mode 100644 index 8b1194dd20c0f..0000000000000 Binary files a/docs/user/ml/images/ml-annotations-list.jpg and /dev/null differ diff --git a/docs/user/ml/images/ml-annotations-list.png b/docs/user/ml/images/ml-annotations-list.png new file mode 100644 index 0000000000000..f1a0b66241126 Binary files /dev/null and b/docs/user/ml/images/ml-annotations-list.png differ diff --git a/docs/user/ml/images/ml-job-management.jpg b/docs/user/ml/images/ml-job-management.jpg deleted file mode 100644 index efdf7923c0faa..0000000000000 Binary files a/docs/user/ml/images/ml-job-management.jpg and /dev/null differ diff --git a/docs/user/ml/images/ml-job-management.png b/docs/user/ml/images/ml-job-management.png new file mode 100644 index 0000000000000..4589c7093a7cf Binary files /dev/null and b/docs/user/ml/images/ml-job-management.png differ diff --git a/docs/user/ml/images/ml-settings.jpg b/docs/user/ml/images/ml-settings.jpg deleted file mode 100644 index 3713be005924d..0000000000000 Binary files a/docs/user/ml/images/ml-settings.jpg and /dev/null differ diff --git a/docs/user/ml/images/ml-settings.png b/docs/user/ml/images/ml-settings.png new file mode 100644 index 0000000000000..f5c9fca647389 Binary files /dev/null and b/docs/user/ml/images/ml-settings.png differ diff --git a/docs/user/ml/images/ml-single-metric-viewer.jpg b/docs/user/ml/images/ml-single-metric-viewer.jpg deleted file mode 100644 index 2fbb9387d1e29..0000000000000 Binary files a/docs/user/ml/images/ml-single-metric-viewer.jpg and /dev/null differ diff --git a/docs/user/ml/images/ml-single-metric-viewer.png b/docs/user/ml/images/ml-single-metric-viewer.png new file mode 100644 index 0000000000000..04c21d9bc533a Binary files /dev/null and b/docs/user/ml/images/ml-single-metric-viewer.png differ diff --git a/docs/user/ml/images/outliers.png b/docs/user/ml/images/outliers.png index 3f4c5f6c6bbf0..874ebbc79201c 100644 Binary files a/docs/user/ml/images/outliers.png and b/docs/user/ml/images/outliers.png differ diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index 1bc74ce87de08..214dae2b96e04 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -47,20 +47,20 @@ create {anomaly-jobs} and manage jobs and {dfeeds} from the *Job Management* pane: [role="screenshot"] -image::user/ml/images/ml-job-management.jpg[Job Management] +image::user/ml/images/ml-job-management.png[Job Management] You can use the *Settings* pane to create and edit {ml-docs}/ml-calendars.html[calendars] and the filters that are used in {ml-docs}/ml-rules.html[custom rules]: [role="screenshot"] -image::user/ml/images/ml-settings.jpg[Calendar Management] +image::user/ml/images/ml-settings.png[Calendar Management] The *Anomaly Explorer* and *Single Metric Viewer* display the results of your {anomaly-jobs}. For example: [role="screenshot"] -image::user/ml/images/ml-single-metric-viewer.jpg[Single Metric Viewer] +image::user/ml/images/ml-single-metric-viewer.png[Single Metric Viewer] You can optionally add annotations by drag-selecting a period of time in the *Single Metric Viewer* and adding a description. For example, you can add an @@ -68,7 +68,7 @@ explanation for anomalies in that time period or provide notes about what is occurring in your operational environment at that time: [role="screenshot"] -image::user/ml/images/ml-annotations-list.jpg[Single Metric Viewer with annotations] +image::user/ml/images/ml-annotations-list.png[Single Metric Viewer with annotations] In some circumstances, annotations are also added automatically. For example, if the {anomaly-job} detects that there is missing data, it annotates the affected @@ -94,8 +94,8 @@ The Elastic {ml} {dfanalytics} feature enables you to analyze your data using indices that contain the results alongside your source data. If you have a license that includes the {ml-features}, you can create -{dfanalytics-jobs} and view their results on the *Analytics* page -in {kib}. For example: +{dfanalytics-jobs} and view their results on the *Data Frame Analytics* page in +{kib}. For example: [role="screenshot"] image::user/ml/images/outliers.png[{oldetection-cap} results in {kib}] diff --git a/docs/visualize/images/vega_lite_tutorial_1.png b/docs/visualize/images/vega_lite_tutorial_1.png new file mode 100644 index 0000000000000..4e8d0aba3635b Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_1.png differ diff --git a/docs/visualize/images/vega_lite_tutorial_2.png b/docs/visualize/images/vega_lite_tutorial_2.png new file mode 100644 index 0000000000000..523ae91514a11 Binary files /dev/null and b/docs/visualize/images/vega_lite_tutorial_2.png differ diff --git a/docs/visualize/images/vega_tutorial_3.png b/docs/visualize/images/vega_tutorial_3.png new file mode 100644 index 0000000000000..e025ecc585807 Binary files /dev/null and b/docs/visualize/images/vega_tutorial_3.png differ diff --git a/docs/visualize/images/vega_tutorial_4.png b/docs/visualize/images/vega_tutorial_4.png new file mode 100644 index 0000000000000..c8ee311e9bf5e Binary files /dev/null and b/docs/visualize/images/vega_tutorial_4.png differ diff --git a/docs/visualize/vega.asciidoc b/docs/visualize/vega.asciidoc index 24bd3a44bebba..3a1c57da93f07 100644 --- a/docs/visualize/vega.asciidoc +++ b/docs/visualize/vega.asciidoc @@ -3,71 +3,1287 @@ experimental[] -Build custom visualizations from multiple data sources using Vega -and Vega-Lite. +Build custom visualizations using Vega and Vega-Lite, backed by one or more +data sources including {es}, Elastic Map Service, URL, +or static data. Use the {kib} extensions to Vega to embed Vega into +your dashboard, and to add interactivity to the visualizations. -* *Vega* — A declarative format to create visualizations using JSON. - Generate interactive displays using D3. +Vega and Vega-Lite are both declarative formats to create visualizations +using JSON. Both use a different syntax for declaring visualizations, +and are not fully interchangeable. -* *Vega-Lite* — An easier format to use than Vega that enables more rapid - data analysis. Compiles into Vega. +[float] +[[when-to-vega]] +=== When to use Vega -For more information about Vega and Vega-Lite, refer to -<>. +Vega and Vega-Lite are capable of building most of the visualizations +that {kib} provides, but with higher complexity. The most common reason +to use Vega in {kib} is that {kib} is missing support for the query or +visualization, for example: -[float] -[[create-vega-viz]] -=== Create Vega visualizations +* Aggregations using the `nested` or `parent/child` mapping +* Aggregations without a {kib} index pattern +* Queries using custom time filters +* Complex calculations +* Extracting data from _source instead of aggregation +* Scatter charts +* Sankey charts +* Custom maps +* Using a visual theme that {kib} does not provide + +[[vega-lite-tutorial]] +=== Tutorial: First visualization in Vega-Lite -You create Vega visualizations by using the text editor, which is -preconfigured with the options you need. +In this tutorial, you will learn about how to edit Vega-Lite in {kib} to create +a stacked area chart from an {es} search query. It will give you a starting point +for a more comprehensive +https://vega.github.io/vega-lite/tutorials/getting_started.html[introduction to Vega-Lite], +while only covering the basics. + +In this tutorial, you will build a stacked area chart from one of the {kib} sample data +sets. [role="screenshot"] -image::images/vega_lite_default.png[] +image::visualize/images/vega_lite_tutorial_1.png[] -[float] -[[vega-schema]] -==== Change the Vega version +Before beginning this tutorial, install the <> +set. + +When you first open the Vega editor in {kib}, you will see a pre-populated +line chart which shows the total number of documents across all your indices +within the time range. -The default visualization uses Vega-Lite version 2. To use Vega version 4, edit -the `schema`. +[role="screenshot"] +image::visualize/images/vega_lite_default.png[] + +The text editor contains a Vega-Lite spec written in https://hjson.github.io/[HJSON], +which is similar to JSON but optimized for human editing. HJSON supports: -Go to `$schema`, enter `https://vega.github.io/schema/vega/v5.json`, then click -*Update*. +* Comments using // or /* syntax +* Object keys without quotes +* String values without quotes +* Optional commas +* Double or single quotes +* Multiline strings [float] -[[vega-type]] -==== Change the visualization type +==== Small steps -The default visualization is a line chart. To change the visualization type, -change the `mark` value. The supported visualization types are listed in the -text editor. +Always work on Vega in the smallest steps possible, and save your work frequently. +Small changes will cause unexpected results. Click the "Save" button now. -Go to `mark`, change the value to a different visualization type, then click -*Update*. +The first step is to change the index to one of the <> +sets. Change + +```yaml +index: _all +``` + +to: + +```yaml +index: kibana_sample_data_ecommerce +``` + +Click "Update". The result is probably not what you expect. You should see a flat +line with 0 results. + +You've only changed the index, so the difference must be the query is returning +no results. You can try the <>, +but intuition may be faster for this particular problem. + +In this case, the problem is that you are querying the field `@timestamp`, +which does not exist in the `kibana_sample_data_ecommerce` data. Find and replace +`@timestamp` with `order_date`. This fixes the problem, leaving you with this spec: + +.Expand Vega-Lite spec +[%collapsible%closed] +==== +[source,yaml] +---- +{ + $schema: https://vega.github.io/schema/vega-lite/v4.json + title: Event counts from ecommerce + data: { + url: { + %context%: true + %timefield%: order_date + index: kibana_sample_data_ecommerce + body: { + aggs: { + time_buckets: { + date_histogram: { + field: order_date + interval: {%autointerval%: true} + extended_bounds: { + min: {%timefilter%: "min"} + max: {%timefilter%: "max"} + } + min_doc_count: 0 + } + } + } + size: 0 + } + } + format: {property: "aggregations.time_buckets.buckets" } + } + + mark: line + + encoding: { + x: { + field: key + type: temporal + axis: { title: null } + } + y: { + field: doc_count + type: quantitative + axis: { title: "Document count" } + } + } +} +---- + +==== + +Now, let's make the visualization more interesting by adding another aggregation +to create a stacked area chart. To verify that you have constructed the right +query, it is easiest to use the {kib} Dev Tools in a separate tab from the +Vega editor. Open the Dev Tools from the Management section of the navigation. + +This query is roughly equivalent to the one that is used in the default +Vega-Lite spec. Copy it into the Dev Tools: + +```js +POST kibana_sample_data_ecommerce/_search +{ + "query": { + "range": { + "order_date": { + "gte": "now-7d" + } + } + }, + "aggs": { + "time_buckets": { + "date_histogram": { + "field": "order_date", + "fixed_interval": "1d", + "extended_bounds": { + "min": "now-7d" + }, + "min_doc_count": 0 + } + } + }, + "size": 0 +} +``` + +There's not enough data to create a stacked bar in the original query, so we +will add a new +{ref}/search-aggregations-bucket-terms-aggregation.html[terms aggregation]: + +```js +POST kibana_sample_data_ecommerce/_search +{ + "query": { + "range": { + "order_date": { + "gte": "now-7d" + } + } + }, + "aggs": { + "categories": { + "terms": { "field": "category.keyword" }, + "aggs": { + "time_buckets": { + "date_histogram": { + "field": "order_date", + "fixed_interval": "1d", + "extended_bounds": { + "min": "now-7d" + }, + "min_doc_count": 0 + } + } + } + } + }, + "size": 0 +} +``` + +You'll see that the response format looks different from the previous query: + +```json +{ + "aggregations" : { + "categories" : { + "doc_count_error_upper_bound" : 0, + "sum_other_doc_count" : 0, + "buckets" : [{ + "key" : "Men's Clothing", + "doc_count" : 1661, + "time_buckets" : { + "buckets" : [{ + "key_as_string" : "2020-06-30T00:00:00.000Z", + "key" : 1593475200000, + "doc_count" : 19 + }, { + "key_as_string" : "2020-07-01T00:00:00.000Z", + "key" : 1593561600000, + "doc_count" : 71 + }] + } + }] + } + } +} +``` + +Now that we have data that we're happy with, it's time to convert from an +isolated {es} query into a query with {kib} integration. Looking at the +<>, you will +see the full list of special tokens that are used in this query, such +as `%context: true`. This query has also replaced `"fixed_interval": "1d"` +with `interval: {%autointerval%: true}`. Copy the final query into +your spec: + +```yaml + data: { + url: { + %context%: true + %timefield%: order_date + index: kibana_sample_data_ecommerce + body: { + aggs: { + categories: { + terms: { field: "category.keyword" } + aggs: { + time_buckets: { + date_histogram: { + field: order_date + interval: {%autointerval%: true} + extended_bounds: { + min: {%timefilter%: "min"} + max: {%timefilter%: "max"} + } + min_doc_count: 0 + } + } + } + } + } + size: 0 + } + } + format: {property: "aggregations.categories.buckets" } + } +``` + +If you copy and paste that into your Vega-Lite spec, and click "Update", +you will see a warning saying `Infinite extent for field "key": [Infinity, -Infinity]`. +Let's use our <> to understand why. + +Vega-Lite generates data using the names `source_0` and `data_0`. `source_0` contains +the results from the {es} query, and `data_0` contains the visually encoded results +which are shown in the chart. To debug this problem, you need to compare both. + +To look at the source, open the browser dev tools console and type +`VEGA_DEBUG.view.data('source_0')`. You will see: + +```js +[{ + doc_count: 454 + key: "Men's Clothing" + time_buckets: {buckets: Array(57)} + Symbol(vega_id): 12822 +}, ...] +``` + +To compare to the visually encoded data, open the browser dev tools console and type +`VEGA_DEBUG.view.data('data_0')`. You will see: + +```js +[{ + doc_count: 454 + key: NaN + time_buckets: {buckets: Array(57)} + Symbol(vega_id): 13879 +}] +``` + +The issue seems to be that the `key` property is not being converted the right way, +which makes sense because the `key` is now `Men's Clothing` instead of a timestamp. + +To fix this, try updating the `encoding` of your Vega-Lite spec to: + +```yaml + encoding: { + x: { + field: time_buckets.buckets.key + type: temporal + axis: { title: null } + } + y: { + field: time_buckets.buckets.doc_count + type: quantitative + axis: { title: "Document count" } + } + } +``` + +This will show more errors, and you can inspect `VEGA_DEBUG.view.data('data_0')` to +understand why. This now shows: + +```js +[{ + doc_count: 454 + key: "Men's Clothing" + time_buckets: {buckets: Array(57)} + time_buckets.buckets.doc_count: undefined + time_buckets.buckets.key: null + Symbol(vega_id): 14094 +}] +``` + +It looks like the problem is that the `time_buckets` inner array is not being +extracted by Vega. The solution is to use a Vega-lite +https://vega.github.io/vega-lite/docs/flatten.html[flatten transformation], available in {kib} 7.9 and later. +If using an older version of Kibana, the flatten transformation is available in Vega +but not Vega-Lite. + +Add this section in between the `data` and `encoding` section: + +```yaml + transform: [{ + flatten: ["time_buckets.buckets"] + }] +``` + +This does not yet produce the results you expect. Inspect the transformed data +by typing `VEGA_DEBUG.view.data('data_0')` into the console again: + +```js +[{ + doc_count: 453 + key: "Men's Clothing" + time_bucket.buckets.doc_count: undefined + time_buckets: {buckets: Array(57)} + time_buckets.buckets: { + key_as_string: "2020-06-30T15:00:00.000Z", + key: 1593529200000, + doc_count: 2 + } + time_buckets.buckets.key: null + Symbol(vega_id): 21564 +}] +``` + +The debug view shows `undefined` values where you would expect to see numbers, and +the cause is that there are duplicate names which are confusing Vega-Lite. This can +be fixed by making this change to the `transform` and `encoding` blocks: + +```yaml + transform: [{ + flatten: ["time_buckets.buckets"], + as: ["buckets"] + }] + + mark: area + + encoding: { + x: { + field: buckets.key + type: temporal + axis: { title: null } + } + y: { + field: buckets.doc_count + type: quantitative + axis: { title: "Document count" } + } + color: { + field: key + type: nominal + } + } +``` + +At this point, you have a stacked area chart that shows the top categories, +but the chart is still missing some common features that we expect from a {kib} +visualization. Let's add hover states and tooltips next. + +Hover states are handled differently in Vega-Lite and Vega. In Vega-Lite this is +done using a concept called `selection`, which has many permutations that are not +covered in this tutorial. We will be adding a simple tooltip and hover state. + +Because {kib} has enabled the https://vega.github.io/vega-lite/docs/tooltip.html[Vega tooltip plugin], +tooltips can be defined in several ways: + +* Automatic tooltip based on the data, via `{ content: "data" }` +* Array of fields, like `[{ field: "key", type: "nominal" }]` +* Defining a custom Javascript object using the `calculate` transform + +For the simple tooltip, add this to your encoding: + +```yaml + encoding: { + tooltip: [{ + field: buckets.key + type: temporal + title: "Date" + }, { + field: key + type: nominal + title: "Category" + }, { + field: buckets.doc_count + type: quantitative + title: "Count" + }] + } +``` + +As you hover over the area series in your chart, a multi-line tooltip will +appear, but it won't indicate the nearest point that it's pointing to. To +indicate the nearest point, we need to add a second layer. + +The first step is to remove the `mark: area` from your visualization. +Once you've removed the previous mark, add a composite mark at the end of +the Vega-Lite spec: + +```yaml + layer: [{ + mark: area + }, { + mark: point + }] +``` + +You'll see that the points are not appearing to line up with the area chart, +and the reason is that the points are not being stacked. Change your Y encoding +to this: + +```yaml + y: { + field: buckets.doc_count + type: quantitative + axis: { title: "Document count" } + stack: true + } +``` + +Now, we will add a `selection` block inside the point mark: + +```yaml + layer: [{ + mark: area + }, { + mark: point + + selection: { + pointhover: { + type: single + on: mouseover + clear: mouseout + empty: none + fields: ["buckets.key", "key"] + nearest: true + } + } + + encoding: { + size: { + condition: { + selection: pointhover + value: 100 + } + value: 5 + } + fill: { + condition: { + selection: pointhover + value: white + } + } + } + }] +``` + +Now that you've enabled a selection, try moving the mouse around the visualization +and seeing the points respond to the nearest position: + +[role="screenshot"] +image::visualize/images/vega_lite_tutorial_2.png[] + +The final result of this tutorial is this spec: + +.Expand final Vega-Lite spec +[%collapsible%closed] +==== +[source,yaml] +---- +{ + $schema: https://vega.github.io/schema/vega-lite/v4.json + title: Event counts from ecommerce + data: { + url: { + %context%: true + %timefield%: order_date + index: kibana_sample_data_ecommerce + body: { + aggs: { + categories: { + terms: { field: "category.keyword" } + aggs: { + time_buckets: { + date_histogram: { + field: order_date + interval: {%autointerval%: true} + extended_bounds: { + min: {%timefilter%: "min"} + max: {%timefilter%: "max"} + } + min_doc_count: 0 + } + } + } + } + } + size: 0 + } + } + format: {property: "aggregations.categories.buckets" } + } + + transform: [{ + flatten: ["time_buckets.buckets"] + as: ["buckets"] + }] + + encoding: { + x: { + field: buckets.key + type: temporal + axis: { title: null } + } + y: { + field: buckets.doc_count + type: quantitative + axis: { title: "Document count" } + stack: true + } + color: { + field: key + type: nominal + title: "Category" + } + tooltip: [{ + field: buckets.key + type: temporal + title: "Date" + }, { + field: key + type: nominal + title: "Category" + }, { + field: buckets.doc_count + type: quantitative + title: "Count" + }] + } + + layer: [{ + mark: area + }, { + mark: point + + selection: { + pointhover: { + type: single + on: mouseover + clear: mouseout + empty: none + fields: ["buckets.key", "key"] + nearest: true + } + } + + encoding: { + size: { + condition: { + selection: pointhover + value: 100 + } + value: 5 + } + fill: { + condition: { + selection: pointhover + value: white + } + } + } + }] +} +---- + +==== + +[[vega-tutorial]] +=== Tutorial: Updating {kib} filters from Vega + +In this tutorial you will build an area chart in Vega using an {es} search query, +and add a click handler and drag handler to update {kib} filters. +This tutorial is not a full https://vega.github.io/vega/tutorials/[Vega tutorial], +but will cover the basics of creating Vega visualizations into {kib}. + +First, create an almost-blank Vega chart by pasting this into the editor: + +```yaml +{ + $schema: "https://vega.github.io/schema/vega/v5.json" + data: [{ + name: source_0 + }] + + scales: [{ + name: x + type: time + range: width + }, { + name: y + type: linear + range: height + }] + + axes: [{ + orient: bottom + scale: x + }, { + orient: left + scale: y + }] + + marks: [ + { + type: area + from: { + data: source_0 + } + encode: { + update: { + } + } + } + ] +} +``` + +Despite being almost blank, this Vega spec still contains the minimum requirements: + +* Data +* Scales +* Marks +* (optional) Axes + +Next, add a valid {es} search query in the `data` block: + +```yaml + data: [ + { + name: source_0 + url: { + %context%: true + %timefield%: order_date + index: kibana_sample_data_ecommerce + body: { + aggs: { + time_buckets: { + date_histogram: { + field: order_date + fixed_interval: "3h" + extended_bounds: { + min: {%timefilter%: "min"} + max: {%timefilter%: "max"} + } + min_doc_count: 0 + } + } + } + size: 0 + } + } + format: { property: "aggregations.time_buckets.buckets" } + } + ] +``` + +Click "Update", and nothing will change in the visualization. The first step +is to change the X and Y scales based on the data: + +```yaml + scales: [{ + name: x + type: time + range: width + domain: { + data: source_0 + field: key + } + }, { + name: y + type: linear + range: height + domain: { + data: source_0 + field: doc_count + } + }] +``` + +Click "Update", and you will see that the X and Y axes are now showing labels based +on the real data. + +Next, encode the fields `key` and `doc_count` as the X and Y values: + +```yaml + marks: [ + { + type: area + from: { + data: source_0 + } + encode: { + update: { + x: { + scale: x + field: key + } + y: { + scale: y + value: 0 + } + y2: { + scale: y + field: doc_count + } + } + } + } + ] +``` + +Click "Update" and you will get a basic area chart: + +[role="screenshot"] +image::visualize/images/vega_tutorial_3.png[] + +Next, add a new block to the `marks` section. This will show clickable points to filter for a specific +date: + +```yaml + { + name: point + type: symbol + style: ["point"] + from: { + data: source_0 + } + encode: { + update: { + x: { + scale: x + field: key + } + y: { + scale: y + field: doc_count + } + size: { + value: 100 + } + fill: { + value: black + } + } + } + } +``` + +Next, we will create a Vega signal to make the points clickable. You can access +the clicked `datum` in the expression used to update. In this case, you want +clicks on points to add a time filter with the 3-hour interval defined above. + +```yaml + signals: [ + { + name: point_click + on: [{ + events: { + source: scope + type: click + markname: point + } + update: '''kibanaSetTimeFilter(datum.key, datum.key + 3 * 60 * 60 * 1000)''' + }] + } + ] +``` + +This event is using the {kib} custom function `kibanaSetTimeFilter` to generate a filter that +gets applied to the entire dashboard on click. + +The mouse cursor does not currently indicate that the chart is interactive. Find the `marks` section, +and update the mark named `point` by adding `cursor: { value: "pointer" }` to +the `encoding` section like this: + +```yaml + { + name: point + type: symbol + style: ["point"] + from: { + data: source_0 + } + encode: { + update: { + ... + cursor: { value: "pointer" } + } + } + } +``` + +Next, we will add a drag interaction which will allow the user to narrow into +a specific time range in the visualization. This will require adding more signals, and +adding a rectangle overlay: + +[role="screenshot"] +image::visualize/images/vega_tutorial_4.png[] + +The first step is to add a new `signal` to track the X position of the cursor: + +```yaml + { + name: currentX + value: -1 + on: [{ + events: { + type: mousemove + source: view + }, + update: "clamp(x(), 0, width)" + }, { + events: { + type: mouseout + source: view + } + update: "-1" + }] + } +``` + +Now add a new `mark` to indicate the current cursor position: + +```yaml + { + type: rule + interactive: false + encode: { + update: { + y: {value: 0} + y2: {signal: "height"} + stroke: {value: "gray"} + strokeDash: { + value: [2, 1] + } + x: {signal: "max(currentX,0)"} + defined: {signal: "currentX > 0"} + } + } + } +``` + +Next, add a signal to track the current selected range, which will update +until the user releases the mouse button or uses the escape key: + + +```yaml + { + name: selected + value: [0, 0] + on: [{ + events: { + type: mousedown + source: view + } + update: "[clamp(x(), 0, width), clamp(x(), 0, width)]" + }, { + events: { + type: mousemove + source: window + consume: true + between: [{ + type: mousedown + source: view + }, { + merge: [{ + type: mouseup + source: window + }, { + type: keydown + source: window + filter: "event.key === 'Escape'" + }] + }] + } + update: "[selected[0], clamp(x(), 0, width)]" + }, { + events: { + type: keydown + source: window + filter: "event.key === 'Escape'" + } + update: "[0, 0]" + }] + } +``` + +Now that there is a signal which tracks the time range from the user, we need to indicate +the range visually by adding a new mark which only appears conditionally: + +```yaml + { + type: rect + name: selectedRect + encode: { + update: { + height: {signal: "height"} + fill: {value: "#333"} + fillOpacity: {value: 0.2} + x: {signal: "selected[0]"} + x2: {signal: "selected[1]"} + defined: {signal: "selected[0] !== selected[1]"} + } + } + } +``` + +Finally, add a new signal which will update the {kib} time filter when the mouse is released while +dragging: + +```yaml + { + name: applyTimeFilter + value: null + on: [{ + events: { + type: mouseup + source: view + } + update: '''selected[0] !== selected[1] ? kibanaSetTimeFilter( + invert('x',selected[0]), + invert('x',selected[1])) : null''' + }] + } +``` + +Putting this all together, your visualization now supports the main features of +standard visualizations in {kib}, but with the potential to add even more control. +The final Vega spec for this tutorial is here: + +.Expand final Vega spec +[%collapsible%closed] +==== +[source,yaml] +---- +{ + $schema: "https://vega.github.io/schema/vega/v5.json" + data: [ + { + name: source_0 + url: { + %context%: true + %timefield%: order_date + index: kibana_sample_data_ecommerce + body: { + aggs: { + time_buckets: { + date_histogram: { + field: order_date + fixed_interval: "3h" + extended_bounds: { + min: {%timefilter%: "min"} + max: {%timefilter%: "max"} + } + min_doc_count: 0 + } + } + } + size: 0 + } + } + format: { property: "aggregations.time_buckets.buckets" } + } + ] + + scales: [{ + name: x + type: time + range: width + domain: { + data: source_0 + field: key + } + }, { + name: y + type: linear + range: height + domain: { + data: source_0 + field: doc_count + } + }] + + axes: [{ + orient: bottom + scale: x + }, { + orient: left + scale: y + }] + + marks: [ + { + type: area + from: { + data: source_0 + } + encode: { + update: { + x: { + scale: x + field: key + } + y: { + scale: y + value: 0 + } + y2: { + scale: y + field: doc_count + } + } + } + }, + { + name: point + type: symbol + style: ["point"] + from: { + data: source_0 + } + encode: { + update: { + x: { + scale: x + field: key + } + y: { + scale: y + field: doc_count + } + size: { + value: 100 + } + fill: { + value: black + } + cursor: { value: "pointer" } + } + } + }, + { + type: rule + interactive: false + encode: { + update: { + y: {value: 0} + y2: {signal: "height"} + stroke: {value: "gray"} + strokeDash: { + value: [2, 1] + } + x: {signal: "max(currentX,0)"} + defined: {signal: "currentX > 0"} + } + } + }, + { + type: rect + name: selectedRect + encode: { + update: { + height: {signal: "height"} + fill: {value: "#333"} + fillOpacity: {value: 0.2} + x: {signal: "selected[0]"} + x2: {signal: "selected[1]"} + defined: {signal: "selected[0] !== selected[1]"} + } + } + } + ] + + signals: [ + { + name: point_click + on: [{ + events: { + source: scope + type: click + markname: point + } + update: '''kibanaSetTimeFilter(datum.key, datum.key + 3 * 60 * 60 * 1000)''' + }] + } + { + name: currentX + value: -1 + on: [{ + events: { + type: mousemove + source: view + }, + update: "clamp(x(), 0, width)" + }, { + events: { + type: mouseout + source: view + } + update: "-1" + }] + } + { + name: selected + value: [0, 0] + on: [{ + events: { + type: mousedown + source: view + } + update: "[clamp(x(), 0, width), clamp(x(), 0, width)]" + }, { + events: { + type: mousemove + source: window + consume: true + between: [{ + type: mousedown + source: view + }, { + merge: [{ + type: mouseup + source: window + }, { + type: keydown + source: window + filter: "event.key === 'Escape'" + }] + }] + } + update: "[selected[0], clamp(x(), 0, width)]" + }, { + events: { + type: keydown + source: window + filter: "event.key === 'Escape'" + } + update: "[0, 0]" + }] + } + { + name: applyTimeFilter + value: null + on: [{ + events: { + type: mouseup + source: view + } + update: '''selected[0] !== selected[1] ? kibanaSetTimeFilter( + invert('x',selected[0]), + invert('x',selected[1])) : null''' + }] + } + ] +} + +---- +==== + +[[vega-reference]] +=== Reference for {kib} extensions + +{kib} has extended Vega and Vega-Lite with extensions that support: + +* Default height and width +* Default theme to match {kib} +* Writing {es} queries using the time range and filters from dashboards +* Using the Elastic Map Service in Vega maps +* Additional tooltip styling +* Advanced setting to enable URL loading from any domain +* Limited debugging support using the browser dev tools +* (Vega only) Expression functions which can update the time range and dashboard filters -[float] [[vega-sizing-and-positioning]] -==== Change the layout +==== Default height and width By default, Vega visualizations use the `autosize = { type: 'fit', contains: 'padding' }` layout. `fit` uses all available space, ignores `width` and `height` values, and respects the padding values. To override this behavior, change the `autosize` value. -[[vega-querying-elasticsearch]] -=== Query {es} +[[vega-theme]] +==== Default theme to match {kib} + +{kib} registers a default https://vega.github.io/vega/docs/schemes/[Vega color scheme] +with the id `elastic`, and sets a default color for each `mark` type. +Override it by providing a different `stroke`, `fill`, or `color` (Vega-Lite) value. + +[[vega-queries]] +==== Writing {es} queries in Vega + +experimental[] {kib} extends the Vega https://vega.github.io/vega/docs/data/[data] elements +with support for direct {es} queries specified as a `url`. -experimental[] Vega https://vega.github.io/vega/docs/data/[data] elements -use embedded and external data with a `"url"` parameter. {kib} adds support for -direct {es} queries by overloading -the `"url"` value. +Because of this, {kib} is **unable to support dynamically loaded data**, +which would otherwise work in Vega. All data is fetched before it's passed to +the Vega renderer. -NOTE: With Vega, you dynamically load your data by setting signals as data URLs. -Since {kib} is unable to support dynamically loaded data, all data is fetched -before it's passed to the Vega renderer. +To define an {es} query in Vega, set the `url` to an object. {kib} will parse +the object looking for special tokens that allow your query to integrate with {kib}. +These tokens are: -For example, count the number of documents in all indices: +* `%context%: true`: Set at the top level, and replaces the `query` section with filters from dashboard +* `%timefield%: `: Set at the top level, integrates the query with the dashboard time filter +* `{%timefilter%: true}`: Replaced by an {es} range query with upper and lower bounds +* `{%timefilter%: "min" | "max"}`: Replaced only by the upper or lower bounds +* `{%timefilter: true, shift: -1, unit: 'hour'}`: Generates a time range query one hour in the past +* `{%autointerval%: true}`: Replaced by the string which contains the automatic {kib} time interval, such as `1h` +* `{%autointerval%: 10}`: Replaced by a string which is approximately dividing the time into 10 ranges, allowing + you to influence the automatic interval +* `"%dashboard_context-must_clause%"`: String replaced by object containing filters +* `"%dashboard_context-filter_clause%"`: String replaced by an object containing filters +* `"%dashboard_context-must_not_clause%"`: String replaced by an object containing filters + +Putting this together, an example query that counts the number of documents in +a specific index: [source,yaml] ---- @@ -80,8 +1296,8 @@ url: { %context%: true // Which indexes to search - index: _all - // The body element may contain "aggs" and "query" subfields + index: kibana_sample_data_logs + // The body element may contain "aggs" and "query" keys body: { aggs: { time_buckets: { @@ -183,7 +1399,7 @@ except that the time range is shifted back by 10 minutes: } ---- -NOTE: When using `"%context%": true` or defining a value for "%timefield%"` the body cannot contain a query. To customize the query within the VEGA specification (e.g. add an additional filter, or shift the timefilter), define your query and use the placeholders as in the example above. The placeholders will be replaced by the actual context of the dashboard or visualization once parsed. +NOTE: When using `"%context%": true` or defining a value for `"%timefield%"` the body cannot contain a query. To customize the query within the VEGA specification (e.g. add an additional filter, or shift the timefilter), define your query and use the placeholders as in the example above. The placeholders will be replaced by the actual context of the dashboard or visualization once parsed. The `"%timefilter%"` can also be used to specify a single min or max value. The date_histogram's `extended_bounds` can be set @@ -194,6 +1410,7 @@ also supported. The `"interval"` can also be set dynamically, depending on the currently picked range: `"interval": {"%autointerval%": 10}` will try to get about 10-15 data points (buckets). +[float] [[vega-esmfiles]] === Access Elastic Map Service files @@ -260,21 +1477,44 @@ Additionally, you can use `latitude`, `longitude`, and `zoom` signals. These signals can be used in the graph, or can be updated to modify the position of the map. -Vega visualization ignore the `autosize`, `width`, `height`, and `padding` -values, using `fit` model with zero padding. +[float] +[[vega-tooltip]] +==== Additional tooltip styling + +{kib} has installed the https://vega.github.io/vega-lite/docs/tooltip.html[Vega tooltip plugin], +so tooltips can be defined in the ways documented there. Beyond that, {kib} also supports +a configuration option for changing the tooltip position and padding: + +```js +{ + config: { + kibana: { + tooltips: { + position: 'top', + padding: 15 + } + } + } +} +``` + +[[vega-url-loading]] +==== Advanced setting to enable URL loading from any domain -[[vega-debugging]] -=== Debugging Vega +Vega can load data from any URL, but this is disabled by default in {kib}. +To change this, set `vis_type_vega.enableExternalUrls: true` in `kibana.yml`, +then restart {kib}. [[vega-browser-debugging-console]] ==== Browser debugging console experimental[] Use browser debugging tools (for example, F12 or Ctrl+Shift+J in Chrome) to inspect the `VEGA_DEBUG` variable: -+ + * `view` — Access to the Vega View object. See https://vega.github.io/vega/docs/api/debugging/[Vega Debugging Guide] -on how to inspect data and signals at runtime. For Vega-Lite, `VEGA_DEBUG.view.data('source_0')` gets the main data set. -For Vega, it uses the data name as defined in your Vega spec. +on how to inspect data and signals at runtime. For Vega-Lite, +`VEGA_DEBUG.view.data('source_0')` gets the pre-transformed data, and `VEGA_DEBUG.view.data('data_0')` +gets the encoded data. For Vega, it uses the data name as defined in your Vega spec. * `vega_spec` — Vega JSON graph specification after some modifications by {kib}. In case of Vega-Lite, this is the output of the Vega-Lite compiler. @@ -283,7 +1523,7 @@ of Vega-Lite, this is the output of the Vega-Lite compiler. Vega-Lite compilation. [[vega-data]] -==== Data +==== Debugging data experimental[] If you are using an {es} query, make sure your resulting data is what you expected. The easiest way to view it is by using the "networking" @@ -294,45 +1534,52 @@ https://www.elastic.co/guide/en/kibana/current/console-kibana.html[Dev Tools]. P `GET /_search`, then add your query as the following lines (just the value of the `"query"` field). -If you need to share your graph with someone, copy the -raw data response to https://gist.github.com/[gist.github.com], possibly -with a `.json` extension, use the `[raw]` button, and use that url -directly in your graph. +[[vega-getting-help]] +==== Asking for help with a Vega spec -To restrict Vega from using non-ES data sources, add `vega.enableExternalUrls: false` -to your kibana.yml file. +Because of the dynamic nature of the data in {es}, it is hard to help you with +Vega specs unless you can share a dataset. To do this, use the browser developer +tools and type: -[[vega-notes]] -[[vega-useful-links]] -=== Resources and examples +`JSON.stringify(VEGA_DEBUG.vegalite_spec, null, 2)` -experimental[] To learn more about Vega and Vega-List, refer to the resources and examples. +Copy the response to https://gist.github.com/[gist.github.com], possibly +with a `.json` extension, use the `[raw]` button, and share that when +asking for help. -==== Vega editor -The https://vega.github.io/editor/[Vega Editor] includes examples for Vega & Vega-Lite, but does not support any -{kib}-specific features like {es} requests and interactive base maps. +[float] +[[vega-expression-functions]] +==== (Vega only) Expression functions which can update the time range and dashboard filters -==== Vega-Lite resources -* https://vega.github.io/vega-lite/tutorials/getting_started.html[Tutorials] -* https://vega.github.io/vega-lite/docs/[Docs] -* https://vega.github.io/vega-lite/examples/[Examples] +{kib} has extended the Vega expression language with these functions: -==== Vega resources -* https://vega.github.io/vega/tutorials/[Tutorials] -* https://vega.github.io/vega/docs/[Docs] -* https://vega.github.io/vega/examples/[Examples] +```js +/** + * @param {object} query Elastic Query DSL snippet, as used in the query DSL editor + * @param {string} [index] as defined in Kibana, or default if missing + */ +kibanaAddFilter(query, index) -TIP: When you use the examples, you may -need to modify the "data" section to use absolute URL. For example, -replace `"url": "data/world-110m.json"` with -`"url": "https://vega.github.io/editor/data/world-110m.json"`. +/** + * @param {object} query Elastic Query DSL snippet, as used in the query DSL editor + * @param {string} [index] as defined in Kibana, or default if missing + */ +kibanaRemoveFilter(query, index) + +kibanaRemoveAllFilters() +/** + * Update dashboard time filter to the new values + * @param {number|string|Date} start + * @param {number|string|Date} end + */ +kibanaSetTimeFilter(start, end) +``` + +[float] [[vega-additional-configuration-options]] ==== Additional configuration options -These options are specific to the {kib}. link:#vega-with-a-map[Map support] has -additional configuration options. - [source,yaml] ---- { @@ -343,12 +1590,37 @@ additional configuration options. controlsLocation: top // Can be `vertical` or `horizontal` (default). controlsDirection: vertical - // If true, hides most of Vega and VegaLite warnings + // If true, hides most of Vega and Vega-Lite warnings hideWarnings: true // Vega renderer to use: `svg` or `canvas` (default) renderer: canvas } } - /* the rest of Vega code */ } ---- + + +[[vega-notes]] +[[vega-useful-links]] +=== Resources and examples + +experimental[] To learn more about Vega and Vega-Lite, refer to the resources and examples. + +==== Vega editor +The https://vega.github.io/editor/[Vega Editor] includes examples for Vega & Vega-Lite, but does not support any +{kib}-specific features like {es} requests and interactive base maps. + +==== Vega-Lite resources +* https://vega.github.io/vega-lite/tutorials/getting_started.html[Tutorials] +* https://vega.github.io/vega-lite/docs/[Docs] +* https://vega.github.io/vega-lite/examples/[Examples] + +==== Vega resources +* https://vega.github.io/vega/tutorials/[Tutorials] +* https://vega.github.io/vega/docs/[Docs] +* https://vega.github.io/vega/examples/[Examples] + +TIP: When you use the examples in {kib}, you may +need to modify the "data" section to use absolute URL. For example, +replace `"url": "data/world-110m.json"` with +`"url": "https://vega.github.io/editor/data/world-110m.json"`. diff --git a/examples/routing_example/README.md b/examples/routing_example/README.md new file mode 100644 index 0000000000000..0a88707bf70bb --- /dev/null +++ b/examples/routing_example/README.md @@ -0,0 +1,9 @@ +Team owner: Platform + +A working example of a plugin that registers and uses multiple custom routes. + +Read more: + +- [IRouter API Docs](../../docs/development/core/server/kibana-plugin-core-server.irouter.md) +- [HttpHandler (core.http.fetch) API Docs](../../docs/development/core/public/kibana-plugin-core-public.httphandler.md) +- [Routing Conventions](../../STYLEGUIDE.md#api-endpoints) \ No newline at end of file diff --git a/examples/routing_example/common/index.ts b/examples/routing_example/common/index.ts new file mode 100644 index 0000000000000..5aa47b1f69cdf --- /dev/null +++ b/examples/routing_example/common/index.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const RANDOM_NUMBER_ROUTE_PATH = '/api/random_number'; + +export const RANDOM_NUMBER_BETWEEN_ROUTE_PATH = '/api/random_number_between'; + +export const POST_MESSAGE_ROUTE_PATH = '/api/post_message'; + +// Internal APIs should use the `internal` prefix, instead of the `api` prefix. +export const INTERNAL_GET_MESSAGE_BY_ID_ROUTE = '/internal/get_message'; diff --git a/examples/routing_example/kibana.json b/examples/routing_example/kibana.json new file mode 100644 index 0000000000000..37851a0da5a85 --- /dev/null +++ b/examples/routing_example/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "routingExample", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["developerExamples"], + "optionalPlugins": [] +} diff --git a/examples/routing_example/public/app.tsx b/examples/routing_example/public/app.tsx new file mode 100644 index 0000000000000..3b33cb33ccb01 --- /dev/null +++ b/examples/routing_example/public/app.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters } from 'kibana/public'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiText, + EuiHorizontalRule, + EuiPageContentHeader, + EuiListGroup, +} from '@elastic/eui'; +import { RandomNumberRouteExample } from './random_number_example'; +import { RandomNumberBetweenRouteExample } from './random_number_between_example'; +import { Services } from './services'; +import { PostMessageRouteExample } from './post_message_example'; +import { GetMessageRouteExample } from './get_message_example'; + +type Props = Services; + +function RoutingExplorer({ + fetchRandomNumber, + fetchRandomNumberBetween, + addSuccessToast, + postMessage, + getMessageById, +}: Props) { + return ( + + + + + +

Routing examples

+
+
+ + + + + + + + + + + + + +
+
+
+ ); +} + +export const renderApp = (props: Props, element: AppMountParameters['element']) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/routing_example/public/get_message_example.tsx b/examples/routing_example/public/get_message_example.tsx new file mode 100644 index 0000000000000..3c34326564d2b --- /dev/null +++ b/examples/routing_example/public/get_message_example.tsx @@ -0,0 +1,96 @@ +/* + * 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, { useCallback } from 'react'; +import { useState } from 'react'; +import { + EuiText, + EuiButton, + EuiLoadingSpinner, + EuiFieldText, + EuiCallOut, + EuiFormRow, +} from '@elastic/eui'; +import { HttpFetchError } from '../../../src/core/public'; +import { isError } from './is_error'; +import { Services } from './services'; + +interface Props { + getMessageById: Services['getMessageById']; +} + +export function GetMessageRouteExample({ getMessageById }: Props) { + const [error, setError] = useState(); + const [isFetching, setIsFetching] = useState(false); + const [message, setMessage] = useState(''); + const [id, setId] = useState(''); + + const doFetch = useCallback(async () => { + if (isFetching) return; + setIsFetching(true); + const response = await getMessageById(id); + + if (isError(response)) { + setError(response); + setMessage(''); + } else { + setError(undefined); + setMessage(response); + } + + setIsFetching(false); + }, [isFetching, getMessageById, setMessage, id]); + + return ( + + +

GET example with param

+ +

This examples uses a simple GET route that takes an id as a param in the route path.

+ + setId(e.target.value)} + data-test-subj="routingExampleGetMessageId" + /> + + + + doFetch()} + > + {isFetching ? : 'Get message'} + + + + {error !== undefined ? ( + + {error.message} + + ) : null} + {message !== '' ? ( +

+ Message is:

{message}
+

+ ) : null} +
+
+ ); +} diff --git a/examples/routing_example/public/index.ts b/examples/routing_example/public/index.ts new file mode 100644 index 0000000000000..2bb703e71cbef --- /dev/null +++ b/examples/routing_example/public/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { PluginInitializer } from 'kibana/public'; +import { RoutingExamplePlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}> = () => new RoutingExamplePlugin(); diff --git a/examples/routing_example/public/is_error.ts b/examples/routing_example/public/is_error.ts new file mode 100644 index 0000000000000..528cca5b50d5d --- /dev/null +++ b/examples/routing_example/public/is_error.ts @@ -0,0 +1,24 @@ +/* + * 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 { HttpFetchError } from '../../../src/core/public'; + +export function isError(error: T | HttpFetchError): error is HttpFetchError { + return error instanceof HttpFetchError; +} diff --git a/examples/routing_example/public/plugin.tsx b/examples/routing_example/public/plugin.tsx new file mode 100644 index 0000000000000..eabdd2ade05b2 --- /dev/null +++ b/examples/routing_example/public/plugin.tsx @@ -0,0 +1,78 @@ +/* + * 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 { + CoreStart, + Plugin, + CoreSetup, + AppMountParameters, + AppNavLinkStatus, +} from '../../../src/core/public'; +import { DeveloperExamplesSetup } from '../../developer_examples/public'; +import { getServices } from './services'; + +interface SetupDeps { + developerExamples: DeveloperExamplesSetup; +} + +export class RoutingExamplePlugin implements Plugin<{}, {}, SetupDeps, {}> { + public setup(core: CoreSetup, { developerExamples }: SetupDeps) { + core.application.register({ + id: 'routingExample', + title: 'Routing', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const [coreStart] = await core.getStartServices(); + const startServices = getServices(coreStart); + const { renderApp } = await import('./app'); + return renderApp(startServices, params.element); + }, + }); + + developerExamples.register({ + appId: 'routingExample', + title: 'Routing', + description: `Examples show how to use core routing and fetch services to register and query your own custom routes.`, + links: [ + { + label: 'IRouter', + href: + 'https://github.com/elastic/kibana/blob/master/docs/development/core/server/kibana-plugin-core-server.irouter.md', + iconType: 'logoGithub', + target: '_blank', + size: 's', + }, + { + label: 'HttpHandler (core.http.fetch)', + href: + 'https://github.com/elastic/kibana/blob/master/docs/development/core/public/kibana-plugin-core-public.httphandler.md', + iconType: 'logoGithub', + target: '_blank', + size: 's', + }, + ], + }); + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/examples/routing_example/public/post_message_example.tsx b/examples/routing_example/public/post_message_example.tsx new file mode 100644 index 0000000000000..3004d66c4aa97 --- /dev/null +++ b/examples/routing_example/public/post_message_example.tsx @@ -0,0 +1,103 @@ +/* + * 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, { useCallback } from 'react'; +import { useState } from 'react'; +import { + EuiText, + EuiButton, + EuiLoadingSpinner, + EuiFieldText, + EuiCallOut, + EuiFormRow, + EuiTextArea, +} from '@elastic/eui'; +import { HttpFetchError } from '../../../src/core/public'; +import { isError } from './is_error'; +import { Services } from './services'; + +interface Props { + postMessage: Services['postMessage']; + addSuccessToast: Services['addSuccessToast']; +} + +export function PostMessageRouteExample({ postMessage, addSuccessToast }: Props) { + const [error, setError] = useState(); + const [isPosting, setIsPosting] = useState(false); + const [message, setMessage] = useState(''); + const [id, setId] = useState(''); + + const doFetch = useCallback(async () => { + if (isPosting) return; + setIsPosting(true); + const response = await postMessage(message, id); + + if (response && isError(response)) { + setError(response); + } else { + setError(undefined); + addSuccessToast('Message was added!'); + setMessage(''); + setId(''); + } + + setIsPosting(false); + }, [isPosting, postMessage, addSuccessToast, setMessage, message, id]); + + return ( + + +

POST example with body

+

+ This examples uses a simple POST route that takes a body parameter and an id as a param in + the route path. +

+ + setId(e.target.value)} + data-test-subj="routingExampleSetMessageId" + /> + + + setMessage(e.target.value)} + /> + + + + doFetch()} + > + {isPosting ? : 'Post message'} + + + + {error !== undefined ? ( + + {error.message} + + ) : null} +
+
+ ); +} diff --git a/examples/routing_example/public/random_number_between_example.tsx b/examples/routing_example/public/random_number_between_example.tsx new file mode 100644 index 0000000000000..9f75060193114 --- /dev/null +++ b/examples/routing_example/public/random_number_between_example.tsx @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useCallback } from 'react'; +import { useState } from 'react'; +import { + EuiText, + EuiButton, + EuiLoadingSpinner, + EuiFieldText, + EuiCallOut, + EuiFormRow, +} from '@elastic/eui'; +import { HttpFetchError } from '../../../src/core/public'; +import { isError } from './is_error'; +import { Services } from './services'; + +interface Props { + fetchRandomNumberBetween: Services['fetchRandomNumberBetween']; +} + +export function RandomNumberBetweenRouteExample({ fetchRandomNumberBetween }: Props) { + const [error, setError] = useState(); + const [randomNumber, setRandomNumber] = useState(0); + const [isFetching, setIsFetching] = useState(false); + const [maxInput, setMaxInput] = useState('10'); + + const doFetch = useCallback(async () => { + if (isFetching) return; + setIsFetching(true); + const response = await fetchRandomNumberBetween(Number.parseInt(maxInput, 10)); + + if (isError(response)) { + setError(response); + } else { + setRandomNumber(response); + } + + setIsFetching(false); + }, [isFetching, maxInput, fetchRandomNumberBetween]); + + return ( + + +

GET example with query

+

+ This examples uses a simple GET route that takes a query parameter in the request and + returns a single number. +

+ + setMaxInput(e.target.value)} + isInvalid={isNaN(Number(maxInput))} + /> + + + + doFetch()} + > + {isFetching ? : 'Generate random number'} + + + + {error !== undefined ? ( + + {error.message} + + ) : null} + {randomNumber > -1 ? ( +

+ Random number is +
{randomNumber}
+

+ ) : null} +
+
+ ); +} diff --git a/examples/routing_example/public/random_number_example.tsx b/examples/routing_example/public/random_number_example.tsx new file mode 100644 index 0000000000000..6b073826c854f --- /dev/null +++ b/examples/routing_example/public/random_number_example.tsx @@ -0,0 +1,78 @@ +/* + * 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, { useCallback } from 'react'; +import { useState } from 'react'; +import { EuiText, EuiButton, EuiLoadingSpinner, EuiCallOut } from '@elastic/eui'; +import { HttpFetchError } from '../../../src/core/public'; +import { Services } from './services'; +import { isError } from './is_error'; + +interface Props { + fetchRandomNumber: Services['fetchRandomNumber']; +} + +export function RandomNumberRouteExample({ fetchRandomNumber }: Props) { + const [error, setError] = useState(undefined); + const [randomNumber, setRandomNumber] = useState(0); + const [isFetching, setIsFetching] = useState(false); + + const doFetch = useCallback(async () => { + if (isFetching) return; + setIsFetching(true); + const response = await fetchRandomNumber(); + + if (isError(response)) { + setError(response); + } else { + setRandomNumber(response); + } + + setIsFetching(false); + }, [isFetching, fetchRandomNumber]); + + return ( + + +

GET example

+

+ This examples uses a simple GET route that takes no parameters or body in the request and + returns a single number. +

+ doFetch()} + > + {isFetching ? : 'Generate a random number'} + + + {error !== undefined ? ( + + {error} + + ) : null} + {randomNumber > -1 ? ( +

+ Random number is
{randomNumber}
+

+ ) : null} +
+
+ ); +} diff --git a/examples/routing_example/public/services.ts b/examples/routing_example/public/services.ts new file mode 100644 index 0000000000000..08a79270372fd --- /dev/null +++ b/examples/routing_example/public/services.ts @@ -0,0 +1,78 @@ +/* + * 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 { CoreStart, HttpFetchError } from 'kibana/public'; +import { + RANDOM_NUMBER_ROUTE_PATH, + RANDOM_NUMBER_BETWEEN_ROUTE_PATH, + POST_MESSAGE_ROUTE_PATH, + INTERNAL_GET_MESSAGE_BY_ID_ROUTE, +} from '../common'; + +export interface Services { + fetchRandomNumber: () => Promise; + fetchRandomNumberBetween: (max: number) => Promise; + postMessage: (message: string, id: string) => Promise; + getMessageById: (id: string) => Promise; + addSuccessToast: (message: string) => void; +} + +export function getServices(core: CoreStart): Services { + return { + addSuccessToast: (message: string) => core.notifications.toasts.addSuccess(message), + fetchRandomNumber: async () => { + try { + const response = await core.http.fetch<{ randomNumber: number }>(RANDOM_NUMBER_ROUTE_PATH); + return response.randomNumber; + } catch (e) { + return e; + } + }, + fetchRandomNumberBetween: async (max: number) => { + try { + const response = await core.http.fetch<{ randomNumber: number }>( + RANDOM_NUMBER_BETWEEN_ROUTE_PATH, + { query: { max } } + ); + return response.randomNumber; + } catch (e) { + return e; + } + }, + postMessage: async (message: string, id: string) => { + try { + await core.http.post(`${POST_MESSAGE_ROUTE_PATH}/${id}`, { + body: JSON.stringify({ message }), + }); + } catch (e) { + return e; + } + }, + getMessageById: async (id: string) => { + try { + const response = await core.http.get<{ message: string }>( + `${INTERNAL_GET_MESSAGE_BY_ID_ROUTE}/${id}` + ); + return response.message; + } catch (e) { + return e; + } + }, + }; +} diff --git a/examples/routing_example/server/index.ts b/examples/routing_example/server/index.ts new file mode 100644 index 0000000000000..77a0d9bb95549 --- /dev/null +++ b/examples/routing_example/server/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { PluginInitializer } from 'kibana/server'; + +import { RoutingExamplePlugin } from './plugin'; + +export const plugin: PluginInitializer<{}, {}> = () => new RoutingExamplePlugin(); diff --git a/examples/routing_example/server/plugin.ts b/examples/routing_example/server/plugin.ts new file mode 100644 index 0000000000000..8e92fafc7b30c --- /dev/null +++ b/examples/routing_example/server/plugin.ts @@ -0,0 +1,37 @@ +/* + * 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 { Plugin, CoreSetup, CoreStart } from 'kibana/server'; +import { registerRoutes } from './routes'; + +export class RoutingExamplePlugin implements Plugin<{}, {}> { + public setup(core: CoreSetup) { + const router = core.http.createRouter(); + + registerRoutes(router); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } + + public stop() {} +} diff --git a/examples/routing_example/server/routes/index.ts b/examples/routing_example/server/routes/index.ts new file mode 100644 index 0000000000000..ea575cf371bb7 --- /dev/null +++ b/examples/routing_example/server/routes/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export { registerRoutes } from './register_routes'; diff --git a/examples/routing_example/server/routes/message_routes.ts b/examples/routing_example/server/routes/message_routes.ts new file mode 100644 index 0000000000000..2b4ec5e11fb4a --- /dev/null +++ b/examples/routing_example/server/routes/message_routes.ts @@ -0,0 +1,90 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { POST_MESSAGE_ROUTE_PATH, INTERNAL_GET_MESSAGE_BY_ID_ROUTE } from '../../common'; + +import { IRouter } from '../../../../src/core/server'; + +/** + * + * NOTE: DON'T USE IN MEMORY DATA STRUCTURES TO STORE DATA! + * + * That won't work in a system with multiple Kibanas, which is a setup we recommend for + * load balancing. I'm only doing so here to simplify the routing example. In real life, + * Elasticsearch should be used to persist data that can be shared across multiple Kibana + * instances. + */ + +const messages: { [key: string]: string } = {}; + +/** + * @param router Pushes a message with an id onto an in memory map. + */ +export function registerPostMessageRoute(router: IRouter) { + router.post( + { + path: `${POST_MESSAGE_ROUTE_PATH}/{id}`, + validate: { + params: schema.object({ + // This parameter name matches the one in POST_MESSAGE_ROUTE_PATH: `api/post_message/{id}`. + // Params are often used for ids like this. + id: schema.string(), + }), + body: schema.object({ + message: schema.string({ maxLength: 100 }), + }), + }, + }, + async (context, request, response) => { + if (messages[request.params.id]) { + return response.badRequest({ + body: `Message with id ${request.params.id} already exists`, + }); + } + + // See note above. NEVER DO THIS IN REAL CODE! Data should only be persisted in Elasticsearch. + messages[request.params.id] = request.body.message; + + return response.ok(); + } + ); +} + +/** + * @param router Returns the message with the given id from an in memory array. + */ +export function registerGetMessageByIdRoute(router: IRouter) { + router.get( + { + path: `${INTERNAL_GET_MESSAGE_BY_ID_ROUTE}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + if (!messages[request.params.id]) { + return response.notFound(); + } + return response.ok({ body: { message: messages[request.params.id] } }); + } + ); +} diff --git a/examples/routing_example/server/routes/random_number_between_generator.ts b/examples/routing_example/server/routes/random_number_between_generator.ts new file mode 100644 index 0000000000000..9481578e540fe --- /dev/null +++ b/examples/routing_example/server/routes/random_number_between_generator.ts @@ -0,0 +1,47 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RANDOM_NUMBER_BETWEEN_ROUTE_PATH } from '../../common'; + +import { IRouter } from '../../../../src/core/server'; + +/** + * + * @param router Registers a get route that returns a random number between one and another number suplied by the user. + */ +export function registerGetRandomNumberBetweenRoute(router: IRouter) { + router.get( + { + path: RANDOM_NUMBER_BETWEEN_ROUTE_PATH, + validate: { + query: schema.object({ + max: schema.number({ defaultValue: 10 }), + }), + }, + }, + async (context, request, response) => { + return response.ok({ + body: { + randomNumber: Math.random() * request.query.max, + }, + }); + } + ); +} diff --git a/examples/routing_example/server/routes/random_number_generator.ts b/examples/routing_example/server/routes/random_number_generator.ts new file mode 100644 index 0000000000000..2cfce45b957ae --- /dev/null +++ b/examples/routing_example/server/routes/random_number_generator.ts @@ -0,0 +1,43 @@ +/* + * 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 { RANDOM_NUMBER_ROUTE_PATH } from '../../common'; + +import { IRouter } from '../../../../src/core/server'; + +/** + * + * @param router Registers a get route that returns a random number between one and ten. It has no input + * parameters, and returns a random number in the body. + */ +export function registerGetRandomNumberRoute(router: IRouter) { + router.get( + { + path: RANDOM_NUMBER_ROUTE_PATH, + validate: {}, + }, + async (context, request, response) => { + return response.ok({ + body: { + randomNumber: Math.random() * 10, + }, + }); + } + ); +} diff --git a/examples/routing_example/server/routes/register_routes.ts b/examples/routing_example/server/routes/register_routes.ts new file mode 100644 index 0000000000000..f556c0ed2c2fd --- /dev/null +++ b/examples/routing_example/server/routes/register_routes.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IRouter } from 'kibana/server'; +import { registerGetRandomNumberRoute } from './random_number_generator'; +import { registerGetRandomNumberBetweenRoute } from './random_number_between_generator'; +import { registerGetMessageByIdRoute, registerPostMessageRoute } from './message_routes'; + +export function registerRoutes(router: IRouter) { + registerGetRandomNumberRoute(router); + registerGetRandomNumberBetweenRoute(router); + registerGetMessageByIdRoute(router); + registerPostMessageRoute(router); +} diff --git a/examples/routing_example/tsconfig.json b/examples/routing_example/tsconfig.json new file mode 100644 index 0000000000000..9bbd9021b2e0a --- /dev/null +++ b/examples/routing_example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "common/**/*.ts", + "../../typings/**/*", + ], + "exclude": [] +} diff --git a/packages/kbn-optimizer/src/report_optimizer_stats.ts b/packages/kbn-optimizer/src/report_optimizer_stats.ts index 2f92f3d648ab7..5057c717efcc3 100644 --- a/packages/kbn-optimizer/src/report_optimizer_stats.ts +++ b/packages/kbn-optimizer/src/report_optimizer_stats.ts @@ -35,6 +35,8 @@ interface Entry { stats: Fs.Stats; } +const IGNORED_EXTNAME = ['.map', '.br', '.gz']; + const getFiles = (dir: string, parent?: string) => flatten( Fs.readdirSync(dir).map((name): Entry | Entry[] => { @@ -51,7 +53,19 @@ const getFiles = (dir: string, parent?: string) => stats, }; }) - ); + ).filter((file) => { + const filename = Path.basename(file.relPath); + if (filename.startsWith('.')) { + return false; + } + + const ext = Path.extname(filename); + if (IGNORED_EXTNAME.includes(ext)) { + return false; + } + + return true; + }); export function reportOptimizerStats(reporter: CiStatsReporter, config: OptimizerConfig) { return pipeClosure((update$: OptimizerUpdate$) => { @@ -70,10 +84,7 @@ export function reportOptimizerStats(reporter: CiStatsReporter, config: Optimize // make the cache read from the cache file since it was likely updated by the worker bundle.cache.refresh(); - const outputFiles = getFiles(bundle.outputDir).filter( - (file) => !(file.relPath.startsWith('.') || file.relPath.endsWith('.map')) - ); - + const outputFiles = getFiles(bundle.outputDir); const entryName = `${bundle.id}.${bundle.type}.js`; const entry = outputFiles.find((f) => f.relPath === entryName); if (!entry) { diff --git a/renovate.json5 b/renovate.json5 index 1ba6dc0ff7e1b..6424894622c9f 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -25,7 +25,7 @@ 'Team:Operations', 'renovate', 'v8.0.0', - 'v7.9.0', + 'v7.10.0', ], major: { labels: [ @@ -33,7 +33,7 @@ 'Team:Operations', 'renovate', 'v8.0.0', - 'v7.9.0', + 'v7.10.0', 'renovate:major', ], }, @@ -254,7 +254,7 @@ 'Team:Operations', 'renovate', 'v8.0.0', - 'v7.9.0', + 'v7.10.0', ':ml', ], }, diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 0662586797164..70b25cb78787a 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -111,8 +111,8 @@ export class DocLinksService { }, kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, siem: { - guide: `${ELASTIC_WEBSITE_URL}guide/en/siem/guide/${DOC_LINK_VERSION}/index.html`, - gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/siem/guide/${DOC_LINK_VERSION}/install-siem.html`, + guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, + gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/install-siem.html`, }, query: { luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`, diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index b36af2a7e4671..4375f09f1ce0b 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -17,11 +17,9 @@ * under the License. */ -import { first } from 'rxjs/operators'; - import { MockLegacyClusterClient, MockClusterClient } from './elasticsearch_service.test.mocks'; - import { BehaviorSubject } from 'rxjs'; +import { first } from 'rxjs/operators'; import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; @@ -227,28 +225,34 @@ describe('#setup', () => { }); it('esNodeVersionCompatibility$ only starts polling when subscribed to', async (done) => { - mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); + const mockedClient = mockClusterClientInstance.asInternalUser; + mockedClient.nodes.info.mockImplementation(() => + elasticsearchClientMock.createClientError(new Error()) + ); const setupContract = await elasticsearchService.setup(setupDeps); await delay(10); - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(0); setupContract.esNodesCompatibility$.subscribe(() => { - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(1); done(); }); }); it('esNodeVersionCompatibility$ stops polling when unsubscribed from', async (done) => { - mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); + const mockedClient = mockClusterClientInstance.asInternalUser; + mockedClient.nodes.info.mockImplementation(() => + elasticsearchClientMock.createClientError(new Error()) + ); const setupContract = await elasticsearchService.setup(setupDeps); - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(0); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(0); const sub = setupContract.esNodesCompatibility$.subscribe(async () => { sub.unsubscribe(); await delay(100); - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(1); done(); }); }); @@ -353,16 +357,19 @@ describe('#stop', () => { it('stops pollEsNodeVersions even if there are active subscriptions', async (done) => { expect.assertions(2); - mockLegacyClusterClientInstance.callAsInternalUser.mockRejectedValue(new Error()); + const mockedClient = mockClusterClientInstance.asInternalUser; + mockedClient.nodes.info.mockImplementation(() => + elasticsearchClientMock.createClientError(new Error()) + ); const setupContract = await elasticsearchService.setup(setupDeps); setupContract.esNodesCompatibility$.subscribe(async () => { - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(1); await elasticsearchService.stop(); await delay(100); - expect(mockLegacyClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockedClient.nodes.info).toHaveBeenCalledTimes(1); done(); }); }); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 9b05fb9887a3b..69bf593dd5862 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -78,9 +78,10 @@ export class ElasticsearchService this.getAuthHeaders = deps.http.getAuthHeaders; this.legacyClient = this.createLegacyClusterClient('data', config); + this.client = this.createClusterClient('data', config); const esNodesCompatibility$ = pollEsNodesVersion({ - callWithInternalUser: this.legacyClient.callAsInternalUser, + internalClient: this.client.asInternalUser, log: this.log, ignoreVersionMismatch: config.ignoreVersionMismatch, esVersionCheckInterval: config.healthCheckDelay.asMilliseconds(), @@ -109,7 +110,6 @@ export class ElasticsearchService } const config = await this.config$.pipe(first()).toPromise(); - this.client = this.createClusterClient('data', config); const createClient = ( type: string, @@ -120,7 +120,7 @@ export class ElasticsearchService }; return { - client: this.client, + client: this.client!, createClient, legacy: { client: this.legacyClient, @@ -133,7 +133,7 @@ export class ElasticsearchService this.log.debug('Stopping elasticsearch service'); this.stop$.next(); if (this.client) { - this.client.close(); + await this.client.close(); } if (this.legacyClient) { this.legacyClient.close(); diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts index 3d1218d4a8e8b..21adac081acf7 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts @@ -18,6 +18,7 @@ */ import { mapNodesVersionCompatibility, pollEsNodesVersion, NodesInfo } from './ensure_es_version'; import { loggingSystemMock } from '../../logging/logging_system.mock'; +import { elasticsearchClientMock } from '../client/mocks'; import { take, delay } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { of } from 'rxjs'; @@ -27,6 +28,9 @@ const mockLogger = mockLoggerFactory.get('mock logger'); const KIBANA_VERSION = '5.1.0'; +const createEsSuccess = elasticsearchClientMock.createClientResponse; +const createEsError = elasticsearchClientMock.createClientError; + function createNodes(...versions: string[]): NodesInfo { const nodes = {} as any; versions @@ -111,25 +115,34 @@ describe('mapNodesVersionCompatibility', () => { }); describe('pollEsNodesVersion', () => { - const callWithInternalUser = jest.fn(); + let internalClient: ReturnType; const getTestScheduler = () => new TestScheduler((actual, expected) => { expect(actual).toEqual(expected); }); beforeEach(() => { - callWithInternalUser.mockReset(); + internalClient = elasticsearchClientMock.createInternalClient(); }); + const nodeInfosSuccessOnce = (infos: NodesInfo) => { + internalClient.nodes.info.mockImplementationOnce(() => createEsSuccess(infos)); + }; + const nodeInfosErrorOnce = (error: any) => { + internalClient.nodes.info.mockImplementationOnce(() => createEsError(error)); + }; + it('returns iscCompatible=false and keeps polling when a poll request throws', (done) => { expect.assertions(3); const expectedCompatibilityResults = [false, false, true]; jest.clearAllMocks(); - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.0', '5.2.0', '5.0.0')); - callWithInternalUser.mockRejectedValueOnce(new Error('mock request error')); - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.0', '5.2.0', '5.1.1-Beta1')); + + nodeInfosSuccessOnce(createNodes('5.1.0', '5.2.0', '5.0.0')); + nodeInfosErrorOnce('mock request error'); + nodeInfosSuccessOnce(createNodes('5.1.0', '5.2.0', '5.1.1-Beta1')); + pollEsNodesVersion({ - callWithInternalUser, + internalClient, esVersionCheckInterval: 1, ignoreVersionMismatch: false, kibanaVersion: KIBANA_VERSION, @@ -148,9 +161,11 @@ describe('pollEsNodesVersion', () => { it('returns compatibility results', (done) => { expect.assertions(1); const nodes = createNodes('5.1.0', '5.2.0', '5.0.0'); - callWithInternalUser.mockResolvedValueOnce(nodes); + + nodeInfosSuccessOnce(nodes); + pollEsNodesVersion({ - callWithInternalUser, + internalClient, esVersionCheckInterval: 1, ignoreVersionMismatch: false, kibanaVersion: KIBANA_VERSION, @@ -168,15 +183,15 @@ describe('pollEsNodesVersion', () => { it('only emits if the node versions changed since the previous poll', (done) => { expect.assertions(4); - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.0', '5.2.0', '5.0.0')); // emit - callWithInternalUser.mockResolvedValueOnce(createNodes('5.0.0', '5.1.0', '5.2.0')); // ignore, same versions, different ordering - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.1', '5.2.0', '5.0.0')); // emit - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.1', '5.1.2', '5.1.3')); // emit - callWithInternalUser.mockResolvedValueOnce(createNodes('5.1.1', '5.1.2', '5.1.3')); // ignore - callWithInternalUser.mockResolvedValueOnce(createNodes('5.0.0', '5.1.0', '5.2.0')); // emit, different from previous version + nodeInfosSuccessOnce(createNodes('5.1.0', '5.2.0', '5.0.0')); // emit + nodeInfosSuccessOnce(createNodes('5.0.0', '5.1.0', '5.2.0')); // ignore, same versions, different ordering + nodeInfosSuccessOnce(createNodes('5.1.1', '5.2.0', '5.0.0')); // emit + nodeInfosSuccessOnce(createNodes('5.1.1', '5.1.2', '5.1.3')); // emit + nodeInfosSuccessOnce(createNodes('5.1.1', '5.1.2', '5.1.3')); // ignore + nodeInfosSuccessOnce(createNodes('5.0.0', '5.1.0', '5.2.0')); // emit, different from previous version pollEsNodesVersion({ - callWithInternalUser, + internalClient, esVersionCheckInterval: 1, ignoreVersionMismatch: false, kibanaVersion: KIBANA_VERSION, @@ -192,14 +207,21 @@ describe('pollEsNodesVersion', () => { it('starts polling immediately and then every esVersionCheckInterval', () => { expect.assertions(1); - callWithInternalUser.mockReturnValueOnce([createNodes('5.1.0', '5.2.0', '5.0.0')]); - callWithInternalUser.mockReturnValueOnce([createNodes('5.1.1', '5.2.0', '5.0.0')]); + + // @ts-expect-error we need to return an incompatible type to use the testScheduler here + internalClient.nodes.info.mockReturnValueOnce([ + { body: createNodes('5.1.0', '5.2.0', '5.0.0') }, + ]); + // @ts-expect-error we need to return an incompatible type to use the testScheduler here + internalClient.nodes.info.mockReturnValueOnce([ + { body: createNodes('5.1.1', '5.2.0', '5.0.0') }, + ]); getTestScheduler().run(({ expectObservable }) => { const expected = 'a 99ms (b|)'; const esNodesCompatibility$ = pollEsNodesVersion({ - callWithInternalUser, + internalClient, esVersionCheckInterval: 100, ignoreVersionMismatch: false, kibanaVersion: KIBANA_VERSION, @@ -227,15 +249,17 @@ describe('pollEsNodesVersion', () => { getTestScheduler().run(({ expectObservable }) => { const expected = '100ms a 99ms (b|)'; - callWithInternalUser.mockReturnValueOnce( - of(createNodes('5.1.0', '5.2.0', '5.0.0')).pipe(delay(100)) + internalClient.nodes.info.mockReturnValueOnce( + // @ts-expect-error we need to return an incompatible type to use the testScheduler here + of({ body: createNodes('5.1.0', '5.2.0', '5.0.0') }).pipe(delay(100)) ); - callWithInternalUser.mockReturnValueOnce( - of(createNodes('5.1.1', '5.2.0', '5.0.0')).pipe(delay(100)) + internalClient.nodes.info.mockReturnValueOnce( + // @ts-expect-error we need to return an incompatible type to use the testScheduler here + of({ body: createNodes('5.1.1', '5.2.0', '5.0.0') }).pipe(delay(100)) ); const esNodesCompatibility$ = pollEsNodesVersion({ - callWithInternalUser, + internalClient, esVersionCheckInterval: 10, ignoreVersionMismatch: false, kibanaVersion: KIBANA_VERSION, @@ -256,6 +280,6 @@ describe('pollEsNodesVersion', () => { }); }); - expect(callWithInternalUser).toHaveBeenCalledTimes(2); + expect(internalClient.nodes.info).toHaveBeenCalledTimes(2); }); }); diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.ts index dc56d982d7b4a..5f926215d167f 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.ts @@ -29,10 +29,10 @@ import { esVersionEqualsKibana, } from './es_kibana_version_compatability'; import { Logger } from '../../logging'; -import { LegacyAPICaller } from '../legacy'; +import type { ElasticsearchClient } from '../client'; export interface PollEsNodesVersionOptions { - callWithInternalUser: LegacyAPICaller; + internalClient: ElasticsearchClient; log: Logger; kibanaVersion: string; ignoreVersionMismatch: boolean; @@ -137,7 +137,7 @@ function compareNodes(prev: NodesVersionCompatibility, curr: NodesVersionCompati } export const pollEsNodesVersion = ({ - callWithInternalUser, + internalClient, log, kibanaVersion, ignoreVersionMismatch, @@ -147,10 +147,11 @@ export const pollEsNodesVersion = ({ return timer(0, healthCheckInterval).pipe( exhaustMap(() => { return from( - callWithInternalUser('nodes.info', { - filterPath: ['nodes.*.version', 'nodes.*.http.publish_address', 'nodes.*.ip'], + internalClient.nodes.info({ + filter_path: ['nodes.*.version', 'nodes.*.http.publish_address', 'nodes.*.ip'], }) ).pipe( + map(({ body }) => body), catchError((_err) => { return of({ nodes: {} }); }) diff --git a/src/dev/renovate/config.ts b/src/dev/renovate/config.ts index d868f0a89b98c..c9688fc0ae0bd 100644 --- a/src/dev/renovate/config.ts +++ b/src/dev/renovate/config.ts @@ -21,7 +21,7 @@ import { RENOVATE_PACKAGE_GROUPS } from './package_groups'; import { PACKAGE_GLOBS } from './package_globs'; import { wordRegExp, maybeFlatMap, maybeMap, getTypePackageName } from './utils'; -const DEFAULT_LABELS = ['release_note:skip', 'Team:Operations', 'renovate', 'v8.0.0', 'v7.9.0']; +const DEFAULT_LABELS = ['release_note:skip', 'Team:Operations', 'renovate', 'v8.0.0', 'v7.10.0']; export const RENOVATE_CONFIG = { extends: ['config:base'], diff --git a/src/es_archiver/lib/indices/create_index_stream.ts b/src/es_archiver/lib/indices/create_index_stream.ts index df9d3bb623ad6..5629f95c7c9c6 100644 --- a/src/es_archiver/lib/indices/create_index_stream.ts +++ b/src/es_archiver/lib/indices/create_index_stream.ts @@ -18,6 +18,8 @@ */ import { Transform, Readable } from 'stream'; +import { inspect } from 'util'; + import { get, once } from 'lodash'; import { Client } from 'elasticsearch'; import { ToolingLog } from '@kbn/dev-utils'; @@ -84,6 +86,18 @@ export function createCreateIndexStream({ stats.createdIndex(index, { settings }); } catch (err) { + if ( + err?.body?.error?.reason?.includes('index exists with the same name as the alias') && + attemptNumber < 3 + ) { + const aliasStr = inspect(aliases); + log.info( + `failed to create aliases [${aliasStr}] because ES indicated an index/alias already exists, trying again` + ); + await attemptToCreate(attemptNumber + 1); + return; + } + if ( get(err, 'body.error.type') !== 'resource_already_exists_exception' || attemptNumber >= 3 diff --git a/src/plugins/data/common/field_formats/converters/url.test.ts b/src/plugins/data/common/field_formats/converters/url.test.ts index 5ee195f8c7752..771bde85626d0 100644 --- a/src/plugins/data/common/field_formats/converters/url.test.ts +++ b/src/plugins/data/common/field_formats/converters/url.test.ts @@ -167,8 +167,8 @@ describe('UrlFormat', () => { }); }); - describe('whitelist', () => { - test('should assume a relative url if the value is not in the whitelist without a base path', () => { + describe('allow-list', () => { + test('should assume a relative url if the value is not in the allow-list without a base path', () => { const parsedUrl = { origin: 'http://kibana', basePath: '', @@ -193,7 +193,7 @@ describe('UrlFormat', () => { ); }); - test('should assume a relative url if the value is not in the whitelist with a basepath', () => { + test('should assume a relative url if the value is not in the allow-list with a basepath', () => { const parsedUrl = { origin: 'http://kibana', basePath: '/xyz', diff --git a/src/plugins/data/common/field_formats/converters/url.ts b/src/plugins/data/common/field_formats/converters/url.ts index b797159b53486..2630c97b0821b 100644 --- a/src/plugins/data/common/field_formats/converters/url.ts +++ b/src/plugins/data/common/field_formats/converters/url.ts @@ -161,8 +161,8 @@ export class UrlFormat extends FieldFormat { return this.generateImgHtml(url, imageLabel); default: - const inWhitelist = allowedUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); - if (!inWhitelist && !parsedUrl) { + const allowed = allowedUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); + if (!allowed && !parsedUrl) { return url; } @@ -178,7 +178,7 @@ export class UrlFormat extends FieldFormat { * UNSUPPORTED * - app/kibana */ - if (!inWhitelist) { + if (!allowed) { // Handles urls like: `#/discover` if (url[0] === '#') { prefix = `${origin}${pathname}`; diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 3ee44aaa0816e..45fa3634bc87e 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -61,7 +61,7 @@ export class DevToolsPlugin implements Plugin { }), updater$: this.appStateUpdater, euiIconType: 'devToolsApp', - order: 9001, + order: 9010, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { const { element, history } = params; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index 5f40c55e30e7e..724908281146d 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { EuiButton, EuiText } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; import { FieldIcon } from '../../../../../kibana_react/public'; @@ -108,6 +108,13 @@ export function DiscoverField({ } }; + function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; + } + return ( <>
- - - {useShortDots ? shortenDottedString(field.name) : field.displayName} - + + {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} {field.name !== '_source' && !selected && ( diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index ae7e915f09773..07efd64752c84 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -23,13 +23,6 @@ margin-bottom: 0; } -.dscFieldList--selected, -.dscFieldList--unpopular, -.dscFieldList--popular { - padding-left: $euiSizeS; - padding-right: $euiSizeS; -} - .dscFieldListHeader { padding: $euiSizeS $euiSizeS 0 $euiSizeS; background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); @@ -40,8 +33,7 @@ } .dscFieldChooser { - padding-left: $euiSizeS !important; - padding-right: $euiSizeS !important; + padding-left: $euiSize; } .dscFieldChooser__toggle { @@ -55,12 +47,12 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0 2px; cursor: pointer; font-size: $euiFontSizeXS; border-top: solid 1px transparent; border-bottom: solid 1px transparent; line-height: normal; + margin-bottom: $euiSizeXS * 0.5; &:hover, &:focus { @@ -72,28 +64,25 @@ .dscSidebarItem--active { border-top: 1px solid $euiColorLightShade; - background: shade($euiColorLightestShade, 5%); color: $euiColorFullShade; - .euiText { - font-weight: bold; - } } .dscSidebarField { - padding: $euiSizeXS 0; + padding: $euiSizeXS; display: flex; - align-items: flex-start; + align-items: center; max-width: 100%; - margin: 0; width: 100%; border: none; - border-radius: 0; + border-radius: $euiBorderRadius - 1px; text-align: left; } .dscSidebarField__name { margin-left: $euiSizeS; flex-grow: 1; + word-break: break-word; + padding-right: 1px; } .dscSidebarField__fieldIcon { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 96e04c13d70e9..e8ed8b80da3bb 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -19,7 +19,7 @@ import './discover_sidebar.scss'; import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiTitle } from '@elastic/eui'; +import { EuiButtonIcon, EuiTitle, EuiSpacer } from '@elastic/eui'; import { sortBy } from 'lodash'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { DiscoverField } from './discover_field'; @@ -199,6 +199,7 @@ export function DiscoverSidebar({ /> +
    { + await PageObjects.common.navigateToApp('routingExample'); + }); + + it('basic get example', async () => { + await retry.try(async () => { + await testSubjects.click('routingExampleFetchRandomNumber'); + const numberAsString = await testSubjects.getVisibleText('routingExampleRandomNumber'); + expect(numberAsString).to.not.be(undefined); + const number = parseFloat(numberAsString); + expect(number).to.be.lessThan(10); + expect(number).to.be.greaterThan(0); + }); + }); + + it('basic get example with query param', async () => { + await retry.try(async () => { + await testSubjects.setValue('routingExampleMaxRandomNumberBetween', '3'); + await testSubjects.click('routingExampleFetchRandomNumberBetween'); + const numberAsString = await testSubjects.getVisibleText( + 'routingExampleRandomNumberBetween' + ); + expect(numberAsString).to.not.be(undefined); + const number = parseFloat(numberAsString); + expect(number).to.be.lessThan(3); + expect(number).to.be.greaterThan(0); + }); + }); + + it('post and get message example', async () => { + await testSubjects.setValue('routingExampleSetMessageId', '234'); + await testSubjects.setValue('routingExampleSetMessage', 'hello!'); + await testSubjects.click('routingExamplePostMessage'); + await testSubjects.setValue('routingExampleGetMessageId', '234'); + await testSubjects.click('routingExampleFetchMessage'); + + await retry.try(async () => { + const message = await testSubjects.getVisibleText('routingExampleGetMessage'); + expect(message).to.be('hello!'); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index f9b0d0a370c06..f3241568bbb3e 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -30,7 +30,8 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); - describe('dashboard filter bar', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71987 + describe.skip('dashboard filter bar', () => { before(async () => { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 97f2641b51d13..160b052e70d30 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -51,42 +51,6 @@ export default function ({ getService, getPageObjects }) { }); }); - describe('data streams', () => { - it('can be an index pattern', async () => { - await es.transport.request({ - path: '/_index_template/generic-logs', - method: 'PUT', - body: { - index_patterns: ['logs-*', 'test_data_stream'], - template: { - mappings: { - properties: { - '@timestamp': { - type: 'date', - }, - }, - }, - }, - data_stream: { - timestamp_field: '@timestamp', - }, - }, - }); - - await es.transport.request({ - path: '/_data_stream/test_data_stream', - method: 'PUT', - }); - - await PageObjects.settings.createIndexPattern('test_data_stream', false); - - await es.transport.request({ - path: '/_data_stream/test_data_stream', - method: 'DELETE', - }); - }); - }); - describe('index alias', () => { it('can be an index pattern', async () => { await es.transport.request({ diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 191572e3e1354..5e8d2ef5653f2 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -28,7 +28,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const security = getService('security'); const PageObjects = getPageObjects(['visualize', 'visualBuilder', 'timePicker', 'visChart']); - describe('visual builder', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/71979 + describe.skip('visual builder', function describeIndexTests() { this.tags('includeFirefox'); beforeEach(async () => { await security.testUser.setRoles([ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 7daf14e99f254..0bb096ecd0f62 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -71,7 +71,7 @@ describe('api', () => { }); test('it calls createIncident correctly', async () => { - const params = { ...apiParams, externalId: null, comments: undefined }; + const params = { ...apiParams, externalId: null, comments: [] }; await api.pushToService({ externalService, mapping, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index bd6f88f5efaa9..3281832941558 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -53,7 +53,7 @@ const pushToServiceHandler = async ({ let incident = {}; // TODO: should be removed later but currently keep it for the Case implementation support - if (mapping) { + if (mapping && Array.isArray(params.comments)) { const fields = prepareFieldsForTransformation({ externalCase: params.externalObject, mapping, diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts index ac7aaf33b7849..8e010d5180f88 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum/rum_dashboard.ts @@ -61,7 +61,10 @@ Then(`should display chart legend`, () => { cy.get('kbnLoadingIndicator').should('not.be.visible'); cy.get('.euiLoadingChart').should('not.be.visible'); - cy.get(chartLegend).eq(0).invoke('text').snapshot(); + cy.get(chartLegend, { timeout: DEFAULT_TIMEOUT }) + .eq(0) + .invoke('text') + .snapshot(); }); Then(`should display tooltip on hover`, () => { @@ -73,7 +76,7 @@ Then(`should display tooltip on hover`, () => { cy.get('kbnLoadingIndicator').should('not.be.visible'); cy.get('.euiLoadingChart').should('not.be.visible'); - const marker = cy.get(pMarkers).eq(0); + const marker = cy.get(pMarkers, { timeout: DEFAULT_TIMEOUT }).eq(0); marker.invoke('show'); marker.trigger('mouseover', { force: true }); cy.get('span[data-cy=percentileTooltipTitle]').should('be.visible'); diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index ab6a6c73843c5..6ec5b73eaa43e 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -276,9 +276,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: { - timestamp_field: string; - }; + data_stream: object; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 670e75f7a241b..fde4e93f8e39f 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -72,6 +72,7 @@ export class IngestManagerPlugin id: PLUGIN_ID, category: DEFAULT_APP_CATEGORIES.management, title: i18n.translate('xpack.ingestManager.appTitle', { defaultMessage: 'Ingest Manager' }), + order: 9020, euiIconType: 'savedObjectsApp', async mount(params: AppMountParameters) { const [coreStart, startDeps] = (await core.getStartServices()) as [ diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 7437321163749..219c2de675359 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -10,7 +10,7 @@ exports[`tests loading base.yml: base.yml 1`] = ` "settings": { "index": { "lifecycle": { - "name": "logs-default" + "name": "logs" }, "codec": "best_compression", "mapping": { @@ -91,9 +91,7 @@ exports[`tests loading base.yml: base.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { @@ -115,7 +113,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "settings": { "index": { "lifecycle": { - "name": "logs-default" + "name": "logs" }, "codec": "best_compression", "mapping": { @@ -196,9 +194,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { @@ -220,7 +216,7 @@ exports[`tests loading system.yml: system.yml 1`] = ` "settings": { "index": { "lifecycle": { - "name": "metrics-default" + "name": "metrics" }, "codec": "best_compression", "mapping": { @@ -1685,9 +1681,7 @@ exports[`tests loading system.yml: system.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index b907c735d2630..876573f2270ea 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -262,7 +262,7 @@ function getBaseTemplate( index: { // ILM Policy must be added here, for now point to the default global ILM policy name lifecycle: { - name: `${type}-default`, + name: type, }, // What should be our default for the compression? codec: 'best_compression', @@ -308,9 +308,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: { - timestamp_field: '@timestamp', - }, + data_stream: {}, composed_of: composedOfTemplates, _meta: { package: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 6854452fd02a4..bb564214e4fab 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -125,6 +125,7 @@ export function IndexPatternDataPanel({ id, title: indexPatterns[id].title, timeFieldName: indexPatterns[id].timeFieldName, + fields: indexPatterns[id].fields, })); const dslQuery = buildSafeEsQuery( @@ -197,6 +198,7 @@ export function IndexPatternDataPanel({ charts={charts} onChangeIndexPattern={onChangeIndexPattern} existingFields={state.existingFields} + existenceFetchFailed={state.existenceFetchFailed} /> )} @@ -231,6 +233,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ currentIndexPatternId, indexPatternRefs, indexPatterns, + existenceFetchFailed, query, dateRange, filters, @@ -249,6 +252,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ onChangeIndexPattern: (newId: string) => void; existingFields: IndexPatternPrivateState['existingFields']; charts: ChartsPluginSetup; + existenceFetchFailed?: boolean; }) { const [localState, setLocalState] = useState({ nameFilter: '', @@ -553,9 +557,15 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ - { - setLocalState((s) => ({ - ...s, - isEmptyAccordionOpen: open, - })); - const displayedFieldLength = - (localState.isAvailableAccordionOpen - ? filteredFieldGroups.availableFields.length - : 0) + (open ? filteredFieldGroups.emptyFields.length : 0); - setPageSize( - Math.max(PAGINATION_SIZE, Math.min(pageSize * 1.5, displayedFieldLength)) - ); - }} - renderCallout={ - - } - /> + {!existenceFetchFailed && ( + { + setLocalState((s) => ({ + ...s, + isEmptyAccordionOpen: open, + })); + const displayedFieldLength = + (localState.isAvailableAccordionOpen + ? filteredFieldGroups.availableFields.length + : 0) + (open ? filteredFieldGroups.emptyFields.length : 0); + setPageSize( + Math.max(PAGINATION_SIZE, Math.min(pageSize * 1.5, displayedFieldLength)) + ); + }} + renderCallout={ + + } + /> + )}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 7cc049c107b87..1d60b0b5cbbee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -6,12 +6,14 @@ import './datapanel.scss'; import React, { memo, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiText, EuiNotificationBadge, EuiSpacer, EuiAccordion, EuiLoadingSpinner, + EuiIconTip, } from '@elastic/eui'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { IndexPatternField } from './types'; @@ -44,6 +46,7 @@ export interface FieldsAccordionProps { fieldProps: FieldItemSharedProps; renderCallout: JSX.Element; exists: boolean; + showExistenceFetchError?: boolean; } export const InnerFieldsAccordion = function InnerFieldsAccordion({ @@ -58,6 +61,7 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ fieldProps, renderCallout, exists, + showExistenceFetchError, }: FieldsAccordionProps) { const renderField = useCallback( (field: IndexPatternField) => { @@ -78,7 +82,18 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ } extraAction={ - hasLoaded ? ( + showExistenceFetchError ? ( + + ) : hasLoaded ? ( {fieldsCount} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index 5776691fbcc7f..27904a0f23f16 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -13,7 +13,7 @@ import { changeLayerIndexPattern, syncExistingFields, } from './loader'; -import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; +import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternField } from './types'; import { documentField } from './document_field'; jest.mock('./operations'); @@ -642,7 +642,11 @@ describe('loader', () => { await syncExistingFields({ dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, - indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + indexPatterns: [ + { id: 'a', title: 'a', fields: [] }, + { id: 'b', title: 'a', fields: [] }, + { id: 'c', title: 'a', fields: [] }, + ], setState, dslQuery, showNoDataPopover: jest.fn(), @@ -662,6 +666,7 @@ describe('loader', () => { expect(newState).toEqual({ foo: 'bar', isFirstExistenceFetch: false, + existenceFetchFailed: false, existingFields: { a: { a_field_1: true, a_field_2: true }, b: { b_field_1: true, b_field_2: true }, @@ -687,7 +692,11 @@ describe('loader', () => { const args = { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, - indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + indexPatterns: [ + { id: 'a', title: 'a', fields: [] }, + { id: 'b', title: 'a', fields: [] }, + { id: 'c', title: 'a', fields: [] }, + ], setState, dslQuery, showNoDataPopover: jest.fn(), @@ -702,5 +711,45 @@ describe('loader', () => { await syncExistingFields({ ...args, isFirstExistenceFetch: true }); expect(showNoDataPopover).not.toHaveBeenCalled(); }); + + it('should set all fields to available and existence error flag if the request fails', async () => { + const setState = jest.fn(); + const fetchJson = (jest.fn((path: string) => { + return new Promise((resolve, reject) => { + reject(new Error()); + }); + }) as unknown) as HttpHandler; + + const args = { + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + fetchJson, + indexPatterns: [ + { + id: 'a', + title: 'a', + fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], + }, + ], + setState, + dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, + }; + + await syncExistingFields(args); + + const [fn] = setState.mock.calls[0]; + const newState = fn({ + foo: 'bar', + existingFields: {}, + }) as IndexPatternPrivateState; + + expect(newState.existenceFetchFailed).toEqual(true); + expect(newState.existingFields.a).toEqual({ + field1: true, + field2: true, + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index e995c7317b5d8..20e7bec6db131 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -246,7 +246,12 @@ export async function syncExistingFields({ showNoDataPopover, }: { dateRange: DateRange; - indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; + indexPatterns: Array<{ + id: string; + title: string; + fields: IndexPatternField[]; + timeFieldName?: string | null; + }>; fetchJson: HttpSetup['post']; setState: SetState; isFirstExistenceFetch: boolean; @@ -254,41 +259,53 @@ export async function syncExistingFields({ dslQuery: object; showNoDataPopover: () => void; }) { - const emptinessInfo = await Promise.all( - indexPatterns.map((pattern) => { - const body: Record = { - dslQuery, - fromDate: dateRange.fromDate, - toDate: dateRange.toDate, - }; - - if (pattern.timeFieldName) { - body.timeFieldName = pattern.timeFieldName; - } + const existenceRequests = indexPatterns.map((pattern) => { + const body: Record = { + dslQuery, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + }; - return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { - body: JSON.stringify(body), - }) as Promise; - }) - ); + if (pattern.timeFieldName) { + body.timeFieldName = pattern.timeFieldName; + } - if (isFirstExistenceFetch) { - const fieldsCurrentIndexPattern = emptinessInfo.find( - (info) => info.indexPatternTitle === currentIndexPatternTitle - ); - if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { - showNoDataPopover(); + return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { + body: JSON.stringify(body), + }) as Promise; + }); + + try { + const emptinessInfo = await Promise.all(existenceRequests); + if (isFirstExistenceFetch) { + const fieldsCurrentIndexPattern = emptinessInfo.find( + (info) => info.indexPatternTitle === currentIndexPatternTitle + ); + if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { + showNoDataPopover(); + } } - } - setState((state) => ({ - ...state, - isFirstExistenceFetch: false, - existingFields: emptinessInfo.reduce((acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, state.existingFields), - })); + setState((state) => ({ + ...state, + isFirstExistenceFetch: false, + existenceFetchFailed: false, + existingFields: emptinessInfo.reduce((acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, state.existingFields), + })); + } catch (e) { + // show all fields as available if fetch failed + setState((state) => ({ + ...state, + existenceFetchFailed: true, + existingFields: indexPatterns.reduce((acc, pattern) => { + acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); + return acc; + }, state.existingFields), + })); + } } function booleanMap(keys: string[]) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index b7beb67196add..2a9b3f452d991 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -52,6 +52,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { */ existingFields: Record>; isFirstExistenceFetch: boolean; + existenceFetchFailed?: boolean; }; export interface IndexPatternRef { diff --git a/x-pack/plugins/maps/README.md b/x-pack/plugins/maps/README.md new file mode 100644 index 0000000000000..aae5a708b680b --- /dev/null +++ b/x-pack/plugins/maps/README.md @@ -0,0 +1,13 @@ +# Maps + +Visualize geo data from Elasticsearch or 3rd party geo-services. + + +## Testing + +Run all tests from the `x-pack` root directory + +- Unit tests: `node scripts/jest --watch maps` +- Functional tests: + - Run `node scripts/functional_tests_server` + - Run `node ../scripts/functional_test_runner.js --config ./test/functional/config.js --grep="maps app"` \ No newline at end of file diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index 35b33da12d384..e73126ef10d3d 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -18,6 +18,7 @@ export type MapFilters = { refreshTimerLastTriggeredAt?: string; timeFilters: TimeRange; zoom: number; + geogridPrecision?: number; }; type ESSearchSourceSyncMeta = { diff --git a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts index 027cc886cd7f7..ed0656a2fc265 100644 --- a/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts +++ b/x-pack/plugins/maps/common/descriptor_types/map_descriptor.ts @@ -10,10 +10,10 @@ import { Query } from '../../../../../src/plugins/data/common'; import { DRAW_TYPE, ES_GEO_FIELD_TYPE, ES_SPATIAL_RELATIONS } from '../constants'; export type MapExtent = { - maxLat: number; - maxLon: number; - minLat: number; minLon: number; + minLat: number; + maxLon: number; + maxLat: number; }; export type MapQuery = Query & { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index e35bb998ce7db..2707b2ac23e58 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -3,18 +3,50 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -jest.mock('../../../kibana_services', () => {}); +import { MapExtent, MapFilters } from '../../../../common/descriptor_types'; + +jest.mock('../../../kibana_services'); jest.mock('ui/new_platform'); +import { + getIndexPatternService, + getSearchService, + fetchSearchSourceAndRecordWithInspector, +} from '../../../kibana_services'; import { ESGeoGridSource } from './es_geo_grid_source'; -import { GRID_RESOLUTION, RENDER_AS, SOURCE_TYPES } from '../../../../common/constants'; +import { + ES_GEO_FIELD_TYPE, + GRID_RESOLUTION, + RENDER_AS, + SOURCE_TYPES, +} from '../../../../common/constants'; +import { SearchSource } from '../../../../../../../src/plugins/data/public/search/search_source'; + +export class MockSearchSource { + setField = jest.fn(); +} describe('ESGeoGridSource', () => { + const geoFieldName = 'bar'; + const mockIndexPatternService = { + get() { + return { + fields: { + getByName() { + return { + name: geoFieldName, + type: ES_GEO_FIELD_TYPE.GEO_POINT, + }; + }, + }, + }; + }, + }; const geogridSource = new ESGeoGridSource( { id: 'foobar', indexPatternId: 'fooIp', - geoField: 'bar', + geoField: geoFieldName, metrics: [], resolution: GRID_RESOLUTION.COARSE, type: SOURCE_TYPES.ES_GEO_GRID, @@ -23,6 +55,144 @@ describe('ESGeoGridSource', () => { {} ); + describe('getGeoJsonWithMeta', () => { + let mockSearchSource: unknown; + beforeEach(async () => { + mockSearchSource = new MockSearchSource(); + const mockSearchService = { + searchSource: { + async create() { + return mockSearchSource as SearchSource; + }, + }, + }; + + // @ts-expect-error + getIndexPatternService.mockReturnValue(mockIndexPatternService); + // @ts-expect-error + getSearchService.mockReturnValue(mockSearchService); + // @ts-expect-error + fetchSearchSourceAndRecordWithInspector.mockReturnValue({ + took: 71, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 748 + 683, + max_score: null, + hits: [], + }, + aggregations: { + gridSplit: { + buckets: [ + { + key: '4/4/6', + doc_count: 748, + gridCentroid: { + location: { + lat: 35.64189018148127, + lon: -82.84314106196105, + }, + count: 748, + }, + }, + { + key: '4/3/6', + doc_count: 683, + gridCentroid: { + location: { + lat: 35.24134021274211, + lon: -98.45945192042787, + }, + count: 683, + }, + }, + ], + }, + }, + }); + }); + + const extent: MapExtent = { + minLon: -160, + minLat: -80, + maxLon: 160, + maxLat: 80, + }; + + const mapFilters: MapFilters = { + geogridPrecision: 4, + filters: [], + timeFilters: { + from: 'now', + to: '15m', + mode: 'relative', + }, + // extent, + buffer: extent, + zoom: 0, + }; + + it('Should configure the SearchSource correctly', async () => { + const { data, meta } = await geogridSource.getGeoJsonWithMeta( + 'foobarLayer', + mapFilters, + () => {} + ); + + expect(meta && meta.areResultsTrimmed).toEqual(false); + expect(data).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-82.84314106196105, 35.64189018148127] }, + id: '4/4/6', + properties: { doc_count: 748 }, + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-98.45945192042787, 35.24134021274211] }, + id: '4/3/6', + properties: { doc_count: 683 }, + }, + ], + }); + + function getProperty(property: string) { + // @ts-expect-error + const call = mockSearchSource.setField.mock.calls.find((c) => { + return c[0] === property; + }); + return call[1]; + } + + expect(getProperty('size')).toEqual(0); + expect(getProperty('query')).toEqual(undefined); + expect(getProperty('filter')).toEqual([ + { + geo_bounding_box: { bar: { bottom_right: [180, -82.67628], top_left: [-180, 82.67628] } }, + }, + ]); + expect(getProperty('aggs')).toEqual({ + gridSplit: { + aggs: { gridCentroid: { geo_centroid: { field: geoFieldName } } }, + geotile_grid: { + bounds: { bottom_right: [160, -80], top_left: [-160, 80] }, + field: 'bar', + precision: 4, + shard_size: 65535, + size: 65535, + }, + }, + }); + }); + }); + describe('getGridResolution', () => { it('should echo gridResoltuion', () => { expect(geogridSource.getGridResolution()).toBe(GRID_RESOLUTION.COARSE); diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_single_layer_source_settings.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_single_layer_source_settings.test.tsx.snap index 699173bd362fa..c82618a500a33 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_single_layer_source_settings.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_single_layer_source_settings.test.tsx.snap @@ -8,7 +8,7 @@ exports[`should not render fields-editor when there is no layername 1`] = ` fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} - label="Tile layer" + label="Source layer" labelType="label" > { label={i18n.translate( 'xpack.maps.source.MVTSingleLayerVectorSourceEditor.layerNameMessage', { - defaultMessage: 'Tile layer', + defaultMessage: 'Source layer', } )} > diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx index bc08baad7a842..4e9e1e9cd7680 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.test.tsx @@ -84,7 +84,7 @@ describe('getImmutableSourceProperties', () => { const source = new MVTSingleLayerVectorSource(descriptor); const properties = await source.getImmutableProperties(); expect(properties).toEqual([ - { label: 'Data source', value: '.pbf vector tiles' }, + { label: 'Data source', value: 'Vector tiles' }, { label: 'Url', value: 'https://example.com/{x}/{y}/{z}.pbf' }, ]); }); diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index ae28828dec5a8..e64d20138cfb8 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -32,7 +32,7 @@ import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_proper export const sourceTitle = i18n.translate( 'xpack.maps.source.MVTSingleLayerVectorSource.sourceTitle', { - defaultMessage: '.pbf vector tiles', + defaultMessage: 'Vector tiles', } ); @@ -127,11 +127,7 @@ export class MVTSingleLayerVectorSource extends AbstractSource }); } - getGeoJsonWithMeta( - layerName: 'string', - searchFilters: unknown[], - registerCancelCallback: (callback: () => void) => void - ): Promise { + getGeoJsonWithMeta(): Promise { // Having this method here is a consequence of ITiledSingleLayerVectorSource extending IVectorSource. throw new Error('Does not implement getGeoJsonWithMeta'); } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts index 42993bf36f618..62fc5a283e5f3 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts @@ -12,8 +12,8 @@ import { IField } from '../../fields/field'; import { ESSearchSourceResponseMeta, MapExtent, + MapFilters, MapQuery, - VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; @@ -42,7 +42,7 @@ export interface IVectorSource extends ISource { ): MapExtent | null; getGeoJsonWithMeta( layerName: 'string', - searchFilters: unknown[], + searchFilters: MapFilters, registerCancelCallback: (callback: () => void) => void ): Promise; @@ -62,8 +62,8 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc registerCancelCallback: (requestToken: symbol, callback: () => void) => void ): MapExtent | null; getGeoJsonWithMeta( - layerName: 'string', - searchFilters: unknown[], + layerName: string, + searchFilters: MapFilters, registerCancelCallback: (callback: () => void) => void ): Promise; diff --git a/x-pack/plugins/maps/public/kibana_services.d.ts b/x-pack/plugins/maps/public/kibana_services.d.ts index 974bccf4942f3..2e6911e89fa0a 100644 --- a/x-pack/plugins/maps/public/kibana_services.d.ts +++ b/x-pack/plugins/maps/public/kibana_services.d.ts @@ -49,6 +49,7 @@ export function getShowMapsInspectorAdapter(): boolean; export function getPreserveDrawingBuffer(): boolean; export function getProxyElasticMapsServiceInMaps(): boolean; export function getIsGoldPlus(): boolean; +export function fetchSearchSourceAndRecordWithInspector(args: unknown): any; export function setLicenseId(args: unknown): void; export function setInspector(args: unknown): void; diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.test.js b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.test.js index 6131ff45c4a0f..a447c4ae7ce84 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.test.js +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.test.js @@ -22,6 +22,7 @@ describe('buildMapsTelemetry', () => { indexPatternsWithGeoFieldCount: 0, indexPatternsWithGeoPointFieldCount: 0, indexPatternsWithGeoShapeFieldCount: 0, + geoShapeAggLayersCount: 0, attributesPerMap: { dataSourcesCount: { avg: 0, @@ -50,48 +51,49 @@ describe('buildMapsTelemetry', () => { indexPatternsWithGeoFieldCount: 3, indexPatternsWithGeoPointFieldCount: 2, indexPatternsWithGeoShapeFieldCount: 1, + geoShapeAggLayersCount: 2, attributesPerMap: { dataSourcesCount: { - avg: 2.6666666666666665, + avg: 2, max: 3, - min: 2, + min: 1, }, emsVectorLayersCount: { canada_provinces: { - avg: 0.3333333333333333, + avg: 0.2, max: 1, min: 1, }, france_departments: { - avg: 0.3333333333333333, + avg: 0.2, max: 1, min: 1, }, italy_provinces: { - avg: 0.3333333333333333, + avg: 0.2, max: 1, min: 1, }, }, layerTypesCount: { TILE: { - avg: 1, + avg: 0.6, max: 1, min: 1, }, VECTOR: { - avg: 1.6666666666666667, + avg: 1.2, max: 2, min: 1, }, }, layersCount: { - avg: 2.6666666666666665, + avg: 2, max: 3, - min: 2, + min: 1, }, }, - mapsTotalCount: 3, + mapsTotalCount: 5, settings: { showMapVisualizationTypes: false, }, diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 5f57d666b9f74..f0286d7e5811f 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -6,13 +6,25 @@ import _ from 'lodash'; import { - SavedObjectsClientContract, - SavedObjectAttributes, SavedObjectAttribute, + SavedObjectAttributes, + SavedObjectsClientContract, } from 'kibana/server'; import { IFieldType, IIndexPattern } from 'src/plugins/data/public'; -import { SOURCE_TYPES, ES_GEO_FIELD_TYPE, MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; -import { LayerDescriptor } from '../../common/descriptor_types'; +import { + ES_GEO_FIELD_TYPE, + LAYER_TYPE, + MAP_SAVED_OBJECT_TYPE, + SCALING_TYPES, + SOURCE_TYPES, +} from '../../common/constants'; +import { + AbstractSourceDescriptor, + ESGeoGridSourceDescriptor, + ESSearchSourceDescriptor, + LayerDescriptor, + SourceDescriptor, +} from '../../common/descriptor_types'; import { MapSavedObject } from '../../common/map_saved_object_type'; // @ts-ignore import { getInternalRepository } from '../kibana_server_services'; @@ -82,6 +94,111 @@ function getIndexPatternsWithGeoFieldCount(indexPatterns: IIndexPattern[]) { }; } +function getEMSLayerCount(layerLists: LayerDescriptor[][]): ILayerTypeCount[] { + return layerLists.map((layerList: LayerDescriptor[]) => { + const emsLayers = layerList.filter((layer: LayerDescriptor) => { + return ( + layer.sourceDescriptor !== null && + layer.sourceDescriptor.type === SOURCE_TYPES.EMS_FILE && + (layer.sourceDescriptor as AbstractSourceDescriptor).id + ); + }); + const emsCountsById = _(emsLayers).countBy((layer: LayerDescriptor) => { + return (layer.sourceDescriptor as AbstractSourceDescriptor).id; + }); + + const layerTypeCount = emsCountsById.value(); + return layerTypeCount as ILayerTypeCount; + }) as ILayerTypeCount[]; +} + +function isFieldGeoShape( + indexPatterns: IIndexPattern[], + indexPatternId: string, + geoField: string | undefined +): boolean { + if (!geoField) { + return false; + } + + const matchIndexPattern = indexPatterns.find((indexPattern: IIndexPattern) => { + return indexPattern.id === indexPatternId; + }); + + if (!matchIndexPattern) { + return false; + } + + const fieldList: IFieldType[] = + matchIndexPattern.attributes && matchIndexPattern.attributes.fields + ? JSON.parse(matchIndexPattern.attributes.fields) + : []; + + const matchField = fieldList.find((field: IFieldType) => { + return field.name === geoField; + }); + + return !!matchField && matchField.type === ES_GEO_FIELD_TYPE.GEO_SHAPE; +} + +function isGeoShapeAggLayer(indexPatterns: IIndexPattern[], layer: LayerDescriptor): boolean { + if (layer.sourceDescriptor === null) { + return false; + } + + if ( + layer.type !== LAYER_TYPE.VECTOR && + layer.type !== LAYER_TYPE.BLENDED_VECTOR && + layer.type !== LAYER_TYPE.HEATMAP + ) { + return false; + } + + const sourceDescriptor: SourceDescriptor = layer.sourceDescriptor; + if (sourceDescriptor.type === SOURCE_TYPES.ES_GEO_GRID) { + return isFieldGeoShape( + indexPatterns, + (sourceDescriptor as ESGeoGridSourceDescriptor).indexPatternId, + (sourceDescriptor as ESGeoGridSourceDescriptor).geoField + ); + } else if ( + sourceDescriptor.type === SOURCE_TYPES.ES_SEARCH && + (sourceDescriptor as ESSearchSourceDescriptor).scalingType === SCALING_TYPES.CLUSTERS + ) { + return isFieldGeoShape( + indexPatterns, + (sourceDescriptor as ESSearchSourceDescriptor).indexPatternId, + (sourceDescriptor as ESSearchSourceDescriptor).geoField + ); + } else { + return false; + } +} + +function getGeoShapeAggCount( + layerLists: LayerDescriptor[][], + indexPatterns: IIndexPattern[] +): number { + const countsPerMap: number[] = layerLists.map((layerList: LayerDescriptor[]) => { + const geoShapeAggLayers = layerList.filter((layerDescriptor) => { + return isGeoShapeAggLayer(indexPatterns, layerDescriptor); + }); + return geoShapeAggLayers.length; + }); + + return _.sum(countsPerMap); +} + +export function getLayerLists(mapSavedObjects: MapSavedObject[]): LayerDescriptor[][] { + return mapSavedObjects.map((savedMapObject) => { + const layerList = + savedMapObject.attributes && savedMapObject.attributes.layerListJSON + ? JSON.parse(savedMapObject.attributes.layerListJSON) + : []; + return layerList as LayerDescriptor[]; + }); +} + export function buildMapsTelemetry({ mapSavedObjects, indexPatternSavedObjects, @@ -91,17 +208,13 @@ export function buildMapsTelemetry({ indexPatternSavedObjects: IIndexPattern[]; settings: SavedObjectAttribute; }): SavedObjectAttributes { - const layerLists = mapSavedObjects.map((savedMapObject) => - savedMapObject.attributes && savedMapObject.attributes.layerListJSON - ? JSON.parse(savedMapObject.attributes.layerListJSON) - : [] - ); + const layerLists: LayerDescriptor[][] = getLayerLists(mapSavedObjects); const mapsCount = layerLists.length; - const dataSourcesCount = layerLists.map((lList) => { + const dataSourcesCount = layerLists.map((layerList: LayerDescriptor[]) => { // todo: not every source-descriptor has an id // @ts-ignore - const sourceIdList = lList.map((layer: LayerDescriptor) => layer.sourceDescriptor.id); + const sourceIdList = layerList.map((layer: LayerDescriptor) => layer.sourceDescriptor.id); return _.uniq(sourceIdList).length; }); @@ -109,15 +222,7 @@ export function buildMapsTelemetry({ const layerTypesCount = layerLists.map((lList) => _.countBy(lList, 'type')); // Count of EMS Vector layers used - const emsLayersCount = layerLists.map((lList) => - _(lList) - .countBy((layer: LayerDescriptor) => { - const isEmsFile = _.get(layer, 'sourceDescriptor.type') === SOURCE_TYPES.EMS_FILE; - return isEmsFile && _.get(layer, 'sourceDescriptor.id'); - }) - .pickBy((val, key) => key !== 'false') - .value() - ) as ILayerTypeCount[]; + const emsLayersCount = getEMSLayerCount(layerLists); const dataSourcesCountSum = _.sum(dataSourcesCount); const layersCountSum = _.sum(layersCount); @@ -127,11 +232,16 @@ export function buildMapsTelemetry({ indexPatternsWithGeoPointFieldCount, indexPatternsWithGeoShapeFieldCount, } = getIndexPatternsWithGeoFieldCount(indexPatternSavedObjects); + + // Tracks whether user users Gold+ only functionality + const geoShapeAggLayersCount = getGeoShapeAggCount(layerLists, indexPatternSavedObjects); + return { settings, indexPatternsWithGeoFieldCount, indexPatternsWithGeoPointFieldCount, indexPatternsWithGeoShapeFieldCount, + geoShapeAggLayersCount, // Total count of maps mapsTotalCount: mapsCount, // Time of capture diff --git a/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json b/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json index 017f9e69ffe46..82a8035c77dc7 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json +++ b/x-pack/plugins/maps/server/maps_telemetry/test_resources/sample_map_saved_objects.json @@ -43,5 +43,38 @@ ], "updated_at": "2019-01-31T23:19:55.855Z", "version": 1 + }, + { + "type": "gis-map", + "id": "643da1e6-c628-11ea-87d0-0242ac130003", + "attributes": { + "title": "Single cluster layer with geo_shape field", + "description": "", + "mapStateJSON": "{\"zoom\":2.12,\"center\":{\"lon\":-88.67592,\"lat\":34.23257},\"timeFilters\":{\"from\":\"now-15m\",\"to\":\"now\",\"mode\":\"quick\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"lucene\"}}", + "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"51afb7d0-c628-11ea-87d0-0242ac130003\",\"geoField\":\"geometry\",\"metrics\":[{\"type\":\"count\"}],\"requestType\":\"point\",\"resolution\":\"COARSE\",\"indexPatternId\":\"4a7f6010-0aed-11ea-9dd2-95afd7ad44d4\"},\"style\":{\"type\":\"VECTOR\",\"properties\":{\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}},\"fillColor\":{\"type\":\"DYNAMIC\",\"options\":{\"color\":\"Blues\",\"colorCategory\":\"palette_0\",\"field\":{\"name\":\"doc_count\",\"origin\":\"source\"},\"fieldMetaOptions\":{\"isEnabled\":true,\"sigma\":3},\"type\":\"ORDINAL\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":0}},\"iconSize\":{\"type\":\"DYNAMIC\",\"options\":{\"minSize\":7,\"maxSize\":32,\"field\":{\"name\":\"doc_count\",\"origin\":\"source\"},\"fieldMetaOptions\":{\"isEnabled\":true,\"sigma\":3}}},\"iconOrientation\":{\"type\":\"STATIC\",\"options\":{\"orientation\":0}},\"labelText\":{\"type\":\"DYNAMIC\",\"options\":{\"field\":{\"name\":\"doc_count\",\"origin\":\"source\"}}},\"labelColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#000000\"}},\"labelSize\":{\"type\":\"STATIC\",\"options\":{\"size\":14}},\"labelBorderColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"labelBorderSize\":{\"options\":{\"size\":\"SMALL\"}}},\"isTimeAware\":true},\"id\":\"8d384d5d-6353-468f-b8f8-8eaa487358c4\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":1,\"visible\":true,\"type\":\"VECTOR\",\"joins\":[]}]", + "uiStateJSON": "{}" + }, + "references": [ + ], + "updated_at": "2019-01-31T23:19:55.855Z", + "version": 1 + }, + { + "type": "gis-map", + "id": "5efd136a-c628-11ea-87d0-0242ac130003", + "attributes": { + "title": "Single heatmap layer with geo_shape field", + "description": "", + "mapStateJSON": "{\"zoom\":2.12,\"center\":{\"lon\":-88.67592,\"lat\":34.23257},\"timeFilters\":{\"from\":\"now-15m\",\"to\":\"now\",\"mode\":\"quick\"},\"refreshConfig\":{\"isPaused\":false,\"interval\":0},\"query\":{\"query\":\"\",\"language\":\"lucene\"}}", + "layerListJSON": "[{\"sourceDescriptor\":{\"type\":\"ES_GEO_GRID\",\"id\":\"51afb7d0-c628-11ea-87d0-0242ac130003\",\"geoField\":\"geometry\",\"metrics\":[{\"type\":\"count\"}],\"requestType\":\"heatmap\",\"resolution\":\"COARSE\",\"indexPatternId\":\"4a7f6010-0aed-11ea-9dd2-95afd7ad44d4\"},\"id\":\"52eade74-1c78-4e18-8670-2061f38b613b\",\"label\":null,\"minZoom\":0,\"maxZoom\":24,\"alpha\":0.75,\"visible\":true,\"style\":{\"type\":\"HEATMAP\",\"colorRampName\":\"theclassic\"},\"type\":\"HEATMAP\",\"joins\":[]}]", + "uiStateJSON": "{}" + }, + "references": [ + ], + "updated_at": "2019-01-31T23:19:55.855Z", + "version": 1 } + ] + + diff --git a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts index 56b372ff39919..653eca126006d 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -10,7 +10,6 @@ import { hasLicenseExpired } from '../license'; import { MlCapabilities, getDefaultCapabilities } from '../../../common/types/capabilities'; import { getCapabilities, getManageMlCapabilities } from './get_capabilities'; -import { ACCESS_DENIED_PATH } from '../management/management_urls'; let _capabilities: MlCapabilities = getDefaultCapabilities(); @@ -25,12 +24,10 @@ export function checkGetManagementMlJobsResolver() { if (isManageML === true && isPlatinumOrTrialLicense === true) { return resolve({ mlFeatureEnabledInSpace }); } else { - window.location.href = ACCESS_DENIED_PATH; return reject(); } }) .catch((e) => { - window.location.href = ACCESS_DENIED_PATH; return reject(); }); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx index 13f3805cdf613..6b99787a6c9a9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import React, { FC } from 'react'; import { isEqual, cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -20,7 +20,6 @@ import { } from '../../hooks/use_create_analytics_form'; import { State } from '../../hooks/use_create_analytics_form/state'; import { DataFrameAnalyticsListRow } from '../analytics_list/common'; -import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; interface PropDefinition { @@ -328,11 +327,11 @@ export function extractCloningConfig({ }) as unknown) as CloneDataFrameAnalyticsConfig; } -export function getCloneAction(createAnalyticsForm: CreateAnalyticsFormProps) { - const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { - defaultMessage: 'Clone job', - }); +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { + defaultMessage: 'Clone job', +}); +export function getCloneAction(createAnalyticsForm: CreateAnalyticsFormProps) { const { actions } = createAnalyticsForm; const onClick = async (item: DeepReadonly) => { @@ -348,23 +347,7 @@ export function getCloneAction(createAnalyticsForm: CreateAnalyticsFormProps) { }; } -interface CloneButtonProps { - item: DataFrameAnalyticsListRow; - createAnalyticsForm: CreateAnalyticsFormProps; -} - -/** - * Temp component to have Clone job button with the same look as the other actions. - * Replace with {@link getCloneAction} as soon as all the actions are refactored - * to support EuiContext with a valid DOM structure without nested buttons. - */ -export const CloneButton: FC = ({ createAnalyticsForm, item }) => { - const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); - - const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.cloneJobButtonLabel', { - defaultMessage: 'Clone job', - }); - +export const useNavigateToWizardWithClonedJob = () => { const { services: { application: { navigateToUrl }, @@ -375,7 +358,7 @@ export const CloneButton: FC = ({ createAnalyticsForm, item }) const savedObjectsClient = savedObjects.client; - const onClick = async () => { + return async (item: DataFrameAnalyticsListRow) => { const sourceIndex = Array.isArray(item.config.source.index) ? item.config.source.index[0] : item.config.source.index; @@ -419,18 +402,46 @@ export const CloneButton: FC = ({ createAnalyticsForm, item }) ); } }; +}; - return ( +interface CloneButtonProps { + isDisabled: boolean; + onClick: () => void; +} + +/** + * Temp component to have Clone job button with the same look as the other actions. + * Replace with {@link getCloneAction} as soon as all the actions are refactored + * to support EuiContext with a valid DOM structure without nested buttons. + */ +export const CloneButton: FC = ({ isDisabled, onClick }) => { + const button = ( {buttonText} ); + + if (isDisabled) { + return ( + + {button} + + ); + } + + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts index b3d7189ff8cda..4e6357c4ea454 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/index.ts @@ -7,6 +7,7 @@ export { extractCloningConfig, isAdvancedConfig, + useNavigateToWizardWithClonedJob, CloneButton, CloneDataFrameAnalyticsConfig, } from './clone_button'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx index 6b745a2c5ff3b..99455a33cf084 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx @@ -49,37 +49,20 @@ describe('DeleteAction', () => { jest.clearAllMocks(); }); - test('When canDeleteDataFrameAnalytics permission is false, button should be disabled.', () => { + test('When isDisabled prop is true, inner button should be disabled.', () => { const { getByTestId } = render( - {}} /> + {}} /> ); + expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled'); }); - test('When canDeleteDataFrameAnalytics permission is true, button should not be disabled.', () => { - const mock = jest.spyOn(CheckPrivilige, 'checkPermission'); - mock.mockImplementation((p) => p === 'canDeleteDataFrameAnalytics'); + test('When isDisabled prop is true, inner button should not be disabled.', () => { const { getByTestId } = render( - {}} /> + {}} /> ); expect(getByTestId('mlAnalyticsJobDeleteButton')).not.toHaveAttribute('disabled'); - - mock.mockRestore(); - }); - - test('When job is running, delete button should be disabled.', () => { - const { getByTestId } = render( - {}} - /> - ); - - expect(getByTestId('mlAnalyticsJobDeleteButton')).toHaveAttribute('disabled'); }); describe('When delete model is open', () => { @@ -93,7 +76,11 @@ describe('DeleteAction', () => { return ( <> {deleteAction.isModalVisible && } - + deleteAction.openModal(mockAnalyticsListItem)} + /> ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx index 7da3bced48576..c83fb6cbac387 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_button.tsx @@ -6,46 +6,42 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from '../analytics_list/common'; +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', { + defaultMessage: 'Delete', +}); + interface DeleteButtonProps { + isDisabled: boolean; item: DataFrameAnalyticsListRow; - onClick: (item: DataFrameAnalyticsListRow) => void; + onClick: () => void; } -export const DeleteButton: FC = ({ item, onClick }) => { - const disabled = isDataFrameAnalyticsRunning(item.stats.state); - const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); - - const buttonDeleteText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', { - defaultMessage: 'Delete', - }); - - const buttonDisabled = disabled || !canDeleteDataFrameAnalytics; - let deleteButton = ( - = ({ isDisabled, item, onClick }) => { + const button = ( + onClick(item)} - aria-label={buttonDeleteText} - style={{ padding: 0 }} + flush="left" + iconType="trash" + isDisabled={isDisabled} + onClick={onClick} + size="s" > - {buttonDeleteText} - + {buttonText} + ); - if (disabled || !canDeleteDataFrameAnalytics) { - deleteButton = ( + if (isDisabled) { + return ( = ({ item, onClick }) => { : createPermissionFailureMessage('canStartStopDataFrameAnalytics') } > - {deleteButton} + {button} ); } - return deleteButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx index 0acb215336faf..764b421821ad0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button.tsx @@ -8,36 +8,34 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { checkPermission } from '../../../../../capabilities/check_capabilities'; +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', { + defaultMessage: 'Edit', +}); interface EditButtonProps { + isDisabled: boolean; onClick: () => void; } -export const EditButton: FC = ({ onClick }) => { - const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); - - const buttonEditText = i18n.translate('xpack.ml.dataframe.analyticsList.editActionName', { - defaultMessage: 'Edit', - }); - - const buttonDisabled = !canCreateDataFrameAnalytics; - const editButton = ( - = ({ isDisabled, onClick }) => { + const button = ( + - {buttonEditText} - + {buttonText} + ); - if (!canCreateDataFrameAnalytics) { + if (isDisabled) { return ( = ({ onClick }) => { defaultMessage: 'You do not have permission to edit analytics jobs.', })} > - {editButton} + {button} ); } - return editButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx index 279a335de8f42..3192a30f8312e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_button.tsx @@ -6,45 +6,46 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; -import { DataFrameAnalyticsListRow, isCompletedAnalyticsJob } from '../analytics_list/common'; +import { DataFrameAnalyticsListRow } from '../analytics_list/common'; + +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.startActionName', { + defaultMessage: 'Start', +}); interface StartButtonProps { + canStartStopDataFrameAnalytics: boolean; + isDisabled: boolean; item: DataFrameAnalyticsListRow; - onClick: (item: DataFrameAnalyticsListRow) => void; + onClick: () => void; } -export const StartButton: FC = ({ item, onClick }) => { - const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); - - const buttonStartText = i18n.translate('xpack.ml.dataframe.analyticsList.startActionName', { - defaultMessage: 'Start', - }); - - // Disable start for analytics jobs which have completed. - const completeAnalytics = isCompletedAnalyticsJob(item.stats); - - const disabled = !canStartStopDataFrameAnalytics || completeAnalytics; - - let startButton = ( - onClick(item)} - aria-label={buttonStartText} +export const StartButton: FC = ({ + canStartStopDataFrameAnalytics, + isDisabled, + item, + onClick, +}) => { + const button = ( + - {buttonStartText} - + {buttonText} + ); - if (!canStartStopDataFrameAnalytics || completeAnalytics) { - startButton = ( + if (isDisabled) { + return ( = ({ item, onClick }) => { }) } > - {startButton} + {button} ); } - return startButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx index b8395f2f7c2a0..a3e8f16daf5ef 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_button.tsx @@ -9,49 +9,46 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { - checkPermission, - createPermissionFailureMessage, -} from '../../../../../capabilities/check_capabilities'; - -import { stopAnalytics } from '../../services/analytics_service'; +import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; import { DataFrameAnalyticsListRow } from '../analytics_list/common'; -const buttonStopText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', { +const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', { defaultMessage: 'Stop', }); interface StopButtonProps { + isDisabled: boolean; item: DataFrameAnalyticsListRow; + onClick: () => void; } -export const StopButton: FC = ({ item }) => { - const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); - - const stopButton = ( +export const StopButton: FC = ({ isDisabled, item, onClick }) => { + const button = ( stopAnalytics(item)} - aria-label={buttonStopText} data-test-subj="mlAnalyticsJobStopButton" + flush="left" + iconType="stop" + isDisabled={isDisabled} + onClick={onClick} + size="s" > - {buttonStopText} + {buttonText} ); - if (!canStartStopDataFrameAnalytics) { + + if (isDisabled) { return ( - {stopButton} + {button} ); } - return stopButton; + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx index 17a18c374dfa6..52b2513d13e39 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/view_button.tsx @@ -6,7 +6,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { getAnalysisType, @@ -31,7 +31,7 @@ export const ViewButton: FC = ({ item, isManagementTable }) => } = useMlKibana(); const analysisType = getAnalysisType(item.config.analysis); - const isDisabled = + const buttonDisabled = !isRegressionAnalysis(item.config.analysis) && !isOutlierAnalysis(item.config.analysis) && !isClassificationAnalysis(item.config.analysis); @@ -41,21 +41,38 @@ export const ViewButton: FC = ({ item, isManagementTable }) => ? () => navigateToApp('ml', { path: url }) : () => navigateToUrl(url); - return ( + const buttonText = i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { + defaultMessage: 'View', + }); + + const button = ( - {i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', { - defaultMessage: 'View', - })} + {buttonText} ); + + if (buttonDisabled) { + return ( + + {button} + + ); + } + + return button; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 4080f6cd7a77e..e2298108ddc4b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -49,7 +49,6 @@ import { } from '../../../../../components/ml_in_memory_table'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { getSelectedJobIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; @@ -72,13 +71,11 @@ interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; blockRefresh?: boolean; - createAnalyticsForm?: CreateAnalyticsFormProps; } export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, blockRefresh = false, - createAnalyticsForm, }) => { const [isInitialized, setIsInitialized] = useState(false); const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); @@ -228,8 +225,7 @@ export const DataFrameAnalyticsList: FC = ({ expandedRowItemIds, setExpandedRowItemIds, isManagementTable, - isMlEnabledInSpace, - createAnalyticsForm + isMlEnabledInSpace ); // Before the analytics have been loaded for the first time, display the loading indicator only. @@ -268,7 +264,7 @@ export const DataFrameAnalyticsList: FC = ({ } actions={ - !isManagementTable && createAnalyticsForm + !isManagementTable ? [ setIsSourceIndexModalVisible(true)} @@ -370,10 +366,10 @@ export const DataFrameAnalyticsList: FC = ({ - {!isManagementTable && createAnalyticsForm && ( + {!isManagementTable && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index cb46a88fa3b21..bc02c81bac0f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -8,8 +8,11 @@ import React from 'react'; import { EuiTableActionsColumnType } from '@elastic/eui'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CloneButton } from '../action_clone'; +import { checkPermission } from '../../../../../capabilities/check_capabilities'; + +import { stopAnalytics } from '../../services/analytics_service'; + +import { useNavigateToWizardWithClonedJob, CloneButton } from '../action_clone'; import { useDeleteAction, DeleteButton, DeleteButtonModal } from '../action_delete'; import { isEditActionFlyoutVisible, @@ -21,15 +24,22 @@ import { useStartAction, StartButton, StartButtonModal } from '../action_start'; import { StopButton } from '../action_stop'; import { getViewAction } from '../action_view'; -import { isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; +import { + isCompletedAnalyticsJob, + isDataFrameAnalyticsRunning, + DataFrameAnalyticsListRow, +} from './common'; export const useActions = ( - createAnalyticsForm: CreateAnalyticsFormProps, isManagementTable: boolean ): { actions: EuiTableActionsColumnType['actions']; modals: JSX.Element | null; } => { + const canCreateDataFrameAnalytics: boolean = checkPermission('canCreateDataFrameAnalytics'); + const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); + const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); + let modals: JSX.Element | null = null; const actions: EuiTableActionsColumnType['actions'] = [ @@ -52,29 +62,93 @@ export const useActions = ( {isEditActionFlyoutVisible(editAction) && } ); + + const startButtonEnabled = (item: DataFrameAnalyticsListRow) => { + if (!isDataFrameAnalyticsRunning(item.stats.state)) { + // Disable start for analytics jobs which have completed. + const completeAnalytics = isCompletedAnalyticsJob(item.stats); + return canStartStopDataFrameAnalytics && !completeAnalytics; + } + return canStartStopDataFrameAnalytics; + }; + + const navigateToWizardWithClonedJob = useNavigateToWizardWithClonedJob(); + actions.push( ...[ { render: (item: DataFrameAnalyticsListRow) => { if (!isDataFrameAnalyticsRunning(item.stats.state)) { - return ; + return ( + { + if (startButtonEnabled(item)) { + startAction.openModal(item); + } + }} + /> + ); } - return ; + + return ( + { + if (canStartStopDataFrameAnalytics) { + stopAnalytics(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return editAction.openFlyout(item)} />; + return ( + { + if (canStartStopDataFrameAnalytics) { + editAction.openFlyout(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return ; + return ( + { + if (canStartStopDataFrameAnalytics) { + deleteAction.openModal(item); + } + }} + /> + ); }, }, { render: (item: DataFrameAnalyticsListRow) => { - return ; + return ( + { + if (canCreateDataFrameAnalytics) { + navigateToWizardWithClonedJob(item); + } + }} + /> + ); }, }, ] diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index fa88396461cd7..123fdada44866 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -22,7 +22,6 @@ import { import { getJobIdUrl } from '../../../../../util/get_job_id_url'; import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { getDataFrameAnalyticsProgress, getDataFrameAnalyticsProgressPhase, @@ -145,10 +144,9 @@ export const useColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, - isMlEnabledInSpace: boolean = true, - createAnalyticsForm?: CreateAnalyticsFormProps + isMlEnabledInSpace: boolean = true ) => { - const { actions, modals } = useActions(createAnalyticsForm!, isManagementTable); + const { actions, modals } = useActions(isManagementTable); function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx index b438a3f006c6c..0597f377d2710 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx @@ -6,37 +6,15 @@ import { mount } from 'enzyme'; import React from 'react'; -import { mountHook } from 'test_utils/enzyme_helpers'; import { CreateAnalyticsButton } from './create_analytics_button'; -import { MlContext } from '../../../../../contexts/ml'; -import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; - -import { useCreateAnalyticsForm } from '../../hooks/use_create_analytics_form'; - -const getMountedHook = () => - mountHook( - () => useCreateAnalyticsForm(), - ({ children }) => ( - {children} - ) - ); - -// workaround to make React.memo() work with enzyme -jest.mock('react', () => { - const r = jest.requireActual('react'); - return { ...r, memo: (x: any) => x }; -}); - jest.mock('../../../../../../../shared_imports'); describe('Data Frame Analytics: ', () => { test('Minimal initialization', () => { - const { getLastHookValue } = getMountedHook(); - const props = getLastHookValue(); const wrapper = mount( - + ); expect(wrapper.find('EuiButton').text()).toBe('Create job'); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx index c7c33787c37bc..e05684b23167c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx @@ -8,26 +8,20 @@ import React, { FC } from 'react'; import { EuiButton, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; -import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -interface Props extends CreateAnalyticsFormProps { +interface Props { + isDisabled: boolean; setIsSourceIndexModalVisible: React.Dispatch>; } -export const CreateAnalyticsButton: FC = ({ - state, - actions, - setIsSourceIndexModalVisible, -}) => { - const { disabled } = state; - +export const CreateAnalyticsButton: FC = ({ isDisabled, setIsSourceIndexModalVisible }) => { const handleClick = () => { setIsSourceIndexModalVisible(true); }; const button = ( = ({ ); - if (disabled) { + if (isDisabled) { return ( { useRefreshInterval(setBlockRefresh); - const createAnalyticsForm = useCreateAnalyticsForm(); - return ( @@ -84,10 +81,7 @@ export const Page: FC = () => { - + diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts new file mode 100644 index 0000000000000..b7c29abc96fb8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.test.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IUiSettingsClient } from 'kibana/public'; +import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; +import { SavedSearchSavedObject } from '../../../../../common/types/kibana'; +import { createSearchItems } from './new_job_utils'; + +describe('createSearchItems', () => { + const kibanaConfig = {} as IUiSettingsClient; + const indexPattern = ({ + fields: [], + } as unknown) as IIndexPattern; + + let savedSearch = ({} as unknown) as SavedSearchSavedObject; + beforeEach(() => { + savedSearch = ({ + client: { + http: { + basePath: { + basePath: '/abc', + serverBasePath: '/abc', + }, + anonymousPaths: {}, + }, + batchQueue: [], + }, + attributes: { + title: 'not test', + description: '', + hits: 0, + columns: ['_source'], + sort: [], + version: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: '', + }, + }, + _version: 'WzI0OSw0XQ==', + id: '4b9b1010-c678-11ea-b6e6-e942978da29c', + type: 'search', + migrationVersion: { + search: '7.4.0', + }, + references: [ + { + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + id: '7e252840-bd27-11ea-8a6c-75d1a0bd08ab', + }, + ], + } as unknown) as SavedSearchSavedObject; + }); + + test('should match index pattern', () => { + const resp = createSearchItems(kibanaConfig, indexPattern, null); + expect(resp).toStrictEqual({ + combinedQuery: { bool: { must: [{ match_all: {} }] } }, + query: { query: '', language: 'lucene' }, + }); + }); + + test('should match saved search with kuery and condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'airline : "AAL" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + should: [{ match_phrase: { airline: 'AAL' } }], + minimum_should_match: 1, + filter: [], + must_not: [], + }, + }, + query: { + language: 'kuery', + query: 'airline : "AAL" ', + }, + }); + }); + + test('should match saved search with kuery and not condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'NOT airline : "AAL" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + filter: [], + must_not: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + airline: 'AAL', + }, + }, + ], + }, + }, + ], + }, + }, + query: { + language: 'kuery', + query: 'NOT airline : "AAL" ', + }, + }); + }); + + test('should match saved search with kuery and condition and not condition', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { query: 'airline : "AAL" and NOT airline : "AWE" ', language: 'kuery' }, + filter: [], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + filter: [ + { bool: { should: [{ match_phrase: { airline: 'AAL' } }], minimum_should_match: 1 } }, + { + bool: { + must_not: { + bool: { should: [{ match_phrase: { airline: 'AWE' } }], minimum_should_match: 1 }, + }, + }, + }, + ], + must_not: [], + }, + }, + query: { query: 'airline : "AAL" and NOT airline : "AWE" ', language: 'kuery' }, + }); + }); + + test('should match saved search with kuery and filter', () => { + const searchSource = { + highlightAll: true, + version: true, + query: { + language: 'kuery', + query: '', + }, + filter: [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'airline', + params: { + query: 'AAL', + }, + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', + }, + query: { + match_phrase: { + airline: 'AAL', + }, + }, + $state: { + store: 'appState', + }, + }, + ], + indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', + }; + savedSearch.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + + const resp = createSearchItems(kibanaConfig, indexPattern, savedSearch); + expect(resp).toStrictEqual({ + combinedQuery: { + bool: { + must: [{ match_all: {} }], + filter: [{ match_phrase: { airline: 'AAL' } }], + must_not: [], + }, + }, + query: { language: 'kuery', query: '' }, + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 9ba10dc21000e..5fa6c817ec4c1 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -52,12 +52,16 @@ export function createSearchItems( } const filterQuery = esQuery.buildQueryFromFilters(filters, indexPattern); - if (combinedQuery.bool.filter === undefined) { - combinedQuery.bool.filter = []; + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; } - if (combinedQuery.bool.must_not === undefined) { - combinedQuery.bool.must_not = []; + + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; } + combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; } else { diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index e3c45c6cd0b04..33bb78c51e013 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -27,6 +27,7 @@ import { getDocLinks } from '../../../../util/dependency_cache'; // @ts-ignore undeclared module import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view/index'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; +import { AccessDeniedPage } from '../access_denied_page'; interface Tab { id: string; @@ -68,6 +69,7 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { const [initialized, setInitialized] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = getTabs(isMlEnabledInSpace); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); @@ -76,12 +78,11 @@ export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { const check = async () => { try { const checkPrivilege = await checkGetManagementMlJobsResolver(); - setInitialized(true); setIsMlEnabledInSpace(checkPrivilege.mlFeatureEnabledInSpace); } catch (e) { - // Silent fail, `checkGetManagementMlJobs()` should redirect when - // there are insufficient permissions. + setAccessDenied(true); } + setInitialized(true); }; useEffect(() => { @@ -120,6 +121,10 @@ export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { ); } + if (accessDenied) { + return ; + } + return ( diff --git a/x-pack/plugins/ml/public/application/management/management_urls.ts b/x-pack/plugins/ml/public/application/management/management_urls.ts index f346940e91ed0..1a83fd2fb4d42 100644 --- a/x-pack/plugins/ml/public/application/management/management_urls.ts +++ b/x-pack/plugins/ml/public/application/management/management_urls.ts @@ -9,4 +9,3 @@ type Path = string; export const MANAGEMENT_PATH: Path = '/management'; export const ML_PATH: Path = `${MANAGEMENT_PATH}/ml`; export const JOBS_LIST_PATH: Path = `${ML_PATH}/jobs_list`; -export const ACCESS_DENIED_PATH: Path = `${ML_PATH}/access_denied`; diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index 1b9ae75a0968e..cfac5e195a127 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -74,7 +74,7 @@ export class MonitoringPlugin const app: App = { id, title, - order: 9002, + order: 9030, euiIconType: icon, category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index f8a6807196557..f53da8fb1f096 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -32,9 +32,9 @@ export function eventName(event: ResolverEvent): string { } } -export function eventId(event: ResolverEvent): string { +export function eventId(event: ResolverEvent): number | undefined | string { if (isLegacyEvent(event)) { - return event.endgame.serial_event_id ? String(event.endgame.serial_event_id) : ''; + return event.endgame.serial_event_id; } return event.event.id; } diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts index 00ddc85a73650..17f905b091e08 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_prebuilt.spec.ts @@ -67,7 +67,7 @@ describe('Alerts rules, prebuilt rules', () => { }); }); -// https://github.com/elastic/kibana/issues/71814 +// FLAKY: https://github.com/elastic/kibana/issues/71814 describe.skip('Deleting prebuilt rules', () => { beforeEach(() => { const expectedNumberOfRules = totalNumberOfPrebuiltRules; diff --git a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx index a560f697de415..7129aa04bdf69 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx @@ -17,7 +17,7 @@ export const CaseSavedObjectNoPermissions = React.memo(() => { { useEffect(() => { chrome.setHelpExtension({ appName: i18n.translate('xpack.securitySolution.chrome.help.appName', { - defaultMessage: 'SIEM', + defaultMessage: 'Security', }), links: [ { content: i18n.translate('xpack.securitySolution.chrome.helpMenu.documentation', { - defaultMessage: 'SIEM documentation', + defaultMessage: 'Security documentation', }), href: docLinks.links.siem.guide, iconType: 'documents', diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx index a34dfb5753d92..0ebf367471848 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/ml_popover.tsx @@ -177,7 +177,7 @@ export const MlPopover = React.memo(() => { values={{ mlDocs: ( diff --git a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts deleted file mode 100644 index 5a3cddb74657d..0000000000000 --- a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts +++ /dev/null @@ -1,16 +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 { ReactWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; - -// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073 -export const waitForUpdates = async

(wrapper: ReactWrapper

) => { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - wrapper.update(); - }); -}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx index 3be2b853925f6..4b454a9ed4d4a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx @@ -56,7 +56,7 @@ const UpdatePrePackagedRulesCallOutComponent: React.FC {i18n.RELEASE_NOTES_HELP} diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx index ce5d19259e9ee..e2e793b34eaf9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx @@ -7,7 +7,6 @@ import React, { FormEvent } from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; -import { waitForUpdates } from '../../../common/utils/test_utils'; import { TestProviders } from '../../../common/mock'; import { ValueListsForm } from './form'; import { useImportList } from '../../../shared_imports'; @@ -30,10 +29,6 @@ const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise { @@ -68,7 +63,6 @@ describe('ValueListsForm', () => { await mockSelectFile(container, mockFile); container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click'); - await waitForUpdates(container); expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile })); }); @@ -80,12 +74,11 @@ describe('ValueListsForm', () => { })); const onError = jest.fn(); - const container = mount( + mount( ); - await waitForUpdates(container); expect(onError).toHaveBeenCalledWith('whoops'); }); @@ -97,12 +90,11 @@ describe('ValueListsForm', () => { })); const onSuccess = jest.fn(); - const container = mount( + mount( ); - await waitForUpdates(container); expect(onSuccess).toHaveBeenCalledWith({ mockResult: true }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx index ab2bc9b2e90e1..175882de551cb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx @@ -9,10 +9,8 @@ import { mount } from 'enzyme'; import { TestProviders } from '../../../common/mock'; import { ValueListsModal } from './modal'; -import { waitForUpdates } from '../../../common/utils/test_utils'; -// TODO: These are occasionally timing out -describe.skip('ValueListsModal', () => { +describe('ValueListsModal', () => { it('renders nothing if showModal is false', () => { const container = mount( @@ -21,20 +19,21 @@ describe.skip('ValueListsModal', () => { ); expect(container.find('EuiModal')).toHaveLength(0); + container.unmount(); }); - it('renders modal if showModal is true', async () => { + it('renders modal if showModal is true', () => { const container = mount( ); - await waitForUpdates(container); expect(container.find('EuiModal')).toHaveLength(1); + container.unmount(); }); - it('calls onClose when modal is closed', async () => { + it('calls onClose when modal is closed', () => { const onClose = jest.fn(); const container = mount( @@ -44,21 +43,19 @@ describe.skip('ValueListsModal', () => { container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click'); - await waitForUpdates(container); - expect(onClose).toHaveBeenCalled(); + container.unmount(); }); - it('renders ValueListsForm and ValueListsTable', async () => { + it('renders ValueListsForm and ValueListsTable', () => { const container = mount( ); - await waitForUpdates(container); - expect(container.find('ValueListsForm')).toHaveLength(1); expect(container.find('ValueListsTable')).toHaveLength(1); + container.unmount(); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx index 59267b5d62a26..32ae585aec191 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx @@ -16,7 +16,7 @@ export const DetectionEngineNoIndex = React.memo(() => { { Map configuration help diff --git a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx index 828e4d3eaaaa0..4d96c213818aa 100644 --- a/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx +++ b/x-pack/plugins/security_solution/public/network/components/embeddables/embedded_map.tsx @@ -185,7 +185,7 @@ export const EmbeddedMapComponent = ({ {i18n.EMBEDDABLE_HEADER_HELP} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index f7c77bc2dfdf8..286cc870378e1 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import '../../common/mock/match_media'; -import { waitForUpdates } from '../../common/utils/test_utils'; import { TestProviders } from '../../common/mock'; import { useWithSource } from '../../common/containers/source'; import { @@ -65,7 +64,7 @@ describe('Overview', () => { mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); }); - it('renders the Setup Instructions text', async () => { + it('renders the Setup Instructions text', () => { const wrapper = mount( @@ -73,11 +72,10 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); - it('does not show Endpoint get ready button when ingest is not enabled', async () => { + it('does not show Endpoint get ready button when ingest is not enabled', () => { const wrapper = mount( @@ -85,11 +83,10 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); }); - it('shows Endpoint get ready button when ingest is enabled', async () => { + it('shows Endpoint get ready button when ingest is enabled', () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -98,12 +95,11 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); }); }); - it('it DOES NOT render the Getting started text when an index is available', async () => { + it('it DOES NOT render the Getting started text when an index is available', () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -120,12 +116,12 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); + wrapper.unmount(); }); - test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => { + test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -147,12 +143,12 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true); + wrapper.unmount(); }); - test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => { + test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -174,12 +170,12 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + wrapper.unmount(); }); - test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => { + test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -196,12 +192,12 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + wrapper.unmount(); }); - test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => { + test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -219,9 +215,10 @@ describe('Overview', () => { ); expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + wrapper.unmount(); }); - test('it does NOT render the Endpoint banner when Ingest is NOT available', async () => { + test('it does NOT render the Endpoint banner when Ingest is NOT available', () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -238,9 +235,9 @@ describe('Overview', () => { ); - await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); + wrapper.unmount(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index 74efb41c4c595..6f26bfe063c05 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -2,6 +2,7 @@ exports[`resolver graph layout when rendering no nodes renders right 1`] = ` Object { + "ariaLevels": Map {}, "edgeLineSegments": Array [], "processNodePositions": Map {}, } @@ -9,6 +10,22 @@ Object { exports[`resolver graph layout when rendering one node renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + }, "edgeLineSegments": Array [], "processNodePositions": Map { Object { @@ -34,6 +51,134 @@ Object { exports[`resolver graph layout when rendering two forks, and one fork has an extra long tine renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "already_running", + "event_type_full": "process_event", + "unique_pid": 1, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 2, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "termination_event", + "event_type_full": "process_event", + "unique_pid": 8, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 3, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 4, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 5, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 6, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 7, + "unique_ppid": 6, + }, + } => 4, + }, "edgeLineSegments": Array [ Object { "metadata": Object { @@ -406,6 +551,36 @@ Object { exports[`resolver graph layout when rendering two nodes, one being the parent of the other renders right 1`] = ` Object { + "ariaLevels": Map { + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "process_name": "", + "unique_pid": 0, + }, + } => 1, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "already_running", + "event_type_full": "process_event", + "unique_pid": 1, + "unique_ppid": 0, + }, + } => 2, + }, "edgeLineSegments": Array [ Object { "metadata": Object { diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index b322de0f34526..35a32d91d8a02 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -4,99 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniquePidForProcess, uniqueParentPidForProcess } from '../process_event'; -import { IndexedProcessTree, AdjacentProcessMap } from '../../types'; +import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event'; +import { IndexedProcessTree } from '../../types'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; /** - * Create a new IndexedProcessTree from an array of ProcessEvents + * Create a new IndexedProcessTree from an array of ProcessEvents. + * siblings will be ordered by timestamp */ -export function factory(processes: ResolverEvent[]): IndexedProcessTree { +export function factory( + // Array of processes to index as a tree + processes: ResolverEvent[] +): IndexedProcessTree { const idToChildren = new Map(); const idToValue = new Map(); - const idToAdjacent = new Map(); - - function emptyAdjacencyMap(id: string): AdjacentProcessMap { - return { - self: id, - parent: null, - firstChild: null, - previousSibling: null, - nextSibling: null, - level: 1, - }; - } - - const roots: ResolverEvent[] = []; for (const process of processes) { const uniqueProcessPid = uniquePidForProcess(process); idToValue.set(uniqueProcessPid, process); - const currentProcessAdjacencyMap: AdjacentProcessMap = - idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid); - idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap); - const uniqueParentPid = uniqueParentPidForProcess(process); - const currentProcessSiblings = idToChildren.get(uniqueParentPid); - - if (currentProcessSiblings) { - const previousProcessId = uniquePidForProcess( - currentProcessSiblings[currentProcessSiblings.length - 1] - ); - currentProcessSiblings.push(process); - /** - * Update adjacency maps for current and previous entries - */ - idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid; - currentProcessAdjacencyMap.previousSibling = previousProcessId; - if (uniqueParentPid) { - currentProcessAdjacencyMap.parent = uniqueParentPid; + // if its defined and not '' + if (uniqueParentPid) { + let siblings = idToChildren.get(uniqueParentPid); + if (!siblings) { + siblings = []; + idToChildren.set(uniqueParentPid, siblings); } - } else { - if (uniqueParentPid) { - idToChildren.set(uniqueParentPid, [process]); - /** - * Get the parent's map, otherwise set an empty one - */ - const parentAdjacencyMap = - idToAdjacent.get(uniqueParentPid) || - (idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)), - idToAdjacent.get(uniqueParentPid))!; - // set firstChild for parent - parentAdjacencyMap.firstChild = uniqueProcessPid; - // set parent for current - currentProcessAdjacencyMap.parent = uniqueParentPid || null; - } else { - // In this case (no unique parent id), it must be a root - roots.push(process); - } - } - } - - /** - * Scan adjacency maps from the top down and assign levels - */ - function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void { - const nextLevel = level + 1; - if (currentProcessMap.nextSibling) { - traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level); - } - if (currentProcessMap.firstChild) { - traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel); + siblings.push(process); } - currentProcessMap.level = level; } - for (const treeRoot of roots) { - traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!); + // sort the children of each node + for (const siblings of idToChildren.values()) { + siblings.sort(orderByTime); } return { idToChildren, idToProcess: idToValue, - idToAdjacent, }; } @@ -109,6 +56,13 @@ export function children(tree: IndexedProcessTree, process: ResolverEvent): Reso return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } +/** + * Get the indexed process event for the ID + */ +export function processEvent(tree: IndexedProcessTree, entityID: string): ResolverEvent | null { + return tree.idToProcess.get(entityID) ?? null; +} + /** * Returns the parent ProcessEvent, if any, for the passed in `childProcess` */ @@ -124,6 +78,31 @@ export function parent( } } +/** + * Returns the following sibling + */ +export function nextSibling( + tree: IndexedProcessTree, + sibling: ResolverEvent +): ResolverEvent | undefined { + const parentNode = parent(tree, sibling); + if (parentNode) { + // The siblings of `sibling` are the children of its parent. + const siblings = children(tree, parentNode); + + // Find the sibling + const index = siblings.indexOf(sibling); + + // if the sibling wasn't found, or if it was the last element in the array, return undefined + if (index === -1 || index === siblings.length - 1) { + return undefined; + } + + // return the next sibling + return siblings[index + 1]; + } +} + /** * Number of processes in the tree */ @@ -138,7 +117,10 @@ export function root(tree: IndexedProcessTree) { if (size(tree) === 0) { return null; } + // any node will do let current: ResolverEvent = tree.idToProcess.values().next().value; + + // iteratively swap current w/ its parent while (parent(tree, current) !== undefined) { current = parent(tree, current)!; } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts index 72d8e878465f7..bd534dcb989e3 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts @@ -148,5 +148,24 @@ describe('resolver graph layout', () => { it('renders right', () => { expect(layout()).toMatchSnapshot(); }); + it('should have node a at level 1', () => { + expect(layout().ariaLevels.get(processA)).toBe(1); + }); + it('should have nodes b and c at level 2', () => { + expect(layout().ariaLevels.get(processB)).toBe(2); + expect(layout().ariaLevels.get(processC)).toBe(2); + }); + it('should have nodes d, e, f, and g at level 3', () => { + expect(layout().ariaLevels.get(processD)).toBe(3); + expect(layout().ariaLevels.get(processE)).toBe(3); + expect(layout().ariaLevels.get(processF)).toBe(3); + expect(layout().ariaLevels.get(processG)).toBe(3); + }); + it('should have node h at level 4', () => { + expect(layout().ariaLevels.get(processH)).toBe(4); + }); + it('should have 9 items in the map of aria levels', () => { + expect(layout().ariaLevels.size).toBe(9); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 61363ffa05d94..6058a40037ad2 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -73,9 +73,34 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso return { processNodePositions: transformedPositions, edgeLineSegments: transformedEdgeLineSegments, + ariaLevels: ariaLevels(indexedProcessTree), }; } +/** + * Calculate a level (starting at 1) for each node. + */ +function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { + const map: Map = new Map(); + for (const node of model.levelOrder(indexedProcessTree)) { + const parentNode = model.parent(indexedProcessTree, node); + if (parentNode === undefined) { + // nodes at the root have a level of 1 + map.set(node, 1); + } else { + const parentLevel: number | undefined = map.get(parentNode); + + // because we're iterating in level order, we should have processed the parent of any node that has one. + if (parentLevel === undefined) { + throw new Error('failed to calculate aria levels'); + } + + map.set(node, parentLevel + 1); + } + } + return map; +} + /** * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its * descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts index 04a3f9ccff772..7eb692851bc9b 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts @@ -3,10 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { eventType } from './process_event'; +import { eventType, orderByTime } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { LegacyEndpointEvent } from '../../../common/endpoint/types'; +import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -24,4 +24,86 @@ describe('process event', () => { expect(eventType(event)).toEqual('processCreated'); }); }); + describe('orderByTime', () => { + let mock: (time: number, eventID: string) => ResolverEvent; + let events: ResolverEvent[]; + beforeEach(() => { + mock = (time, eventID) => { + return { + '@timestamp': time, + event: { + id: eventID, + }, + } as ResolverEvent; + }; + // 2 events each for numbers -1, 0, 1, and NaN + // each event has a unique id, a through h + // order is arbitrary + events = [ + mock(-1, 'a'), + mock(0, 'c'), + mock(1, 'e'), + mock(NaN, 'g'), + mock(-1, 'b'), + mock(0, 'd'), + mock(1, 'f'), + mock(NaN, 'h'), + ]; + }); + it('sorts events as expected', () => { + events.sort(orderByTime); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "@timestamp": -1, + "event": Object { + "id": "a", + }, + }, + Object { + "@timestamp": -1, + "event": Object { + "id": "b", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "c", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "d", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "e", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "f", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "g", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "h", + }, + }, + ] + `); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 0286cca93b43f..4f8df87b3ac0b 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -28,6 +28,19 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { return eventType(passedEvent) === 'processTerminated'; } +/** + * ms since unix epoc, based on timestamp. + * may return NaN if the timestamp wasn't present or was invalid. + */ +export function datetime(passedEvent: ResolverEvent): number | null { + const timestamp = event.eventTimestamp(passedEvent); + + const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); + + // if the date could not be parsed, return null + return isNaN(time) ? null : time; +} + /** * Returns a custom event type for a process event based on the event's metadata. */ @@ -161,3 +174,22 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { } return passedEvent?.process?.args; } + +/** + * used to sort events + */ +export function orderByTime(first: ResolverEvent, second: ResolverEvent): number { + const firstDatetime: number | null = datetime(first); + const secondDatetime: number | null = datetime(second); + + if (firstDatetime === secondDatetime) { + // break ties using an arbitrary (stable) comparison of `eventId` (which should be unique) + return String(event.eventId(first)).localeCompare(String(event.eventId(second))); + } else if (firstDatetime === null || secondDatetime === null) { + // sort `null`'s as higher than numbers + return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0); + } else { + // sort in ascending order. + return firstDatetime - secondDatetime; + } +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 475546cfc3966..dc17fc70ef8af 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -8,7 +8,6 @@ import rbush from 'rbush'; import { createSelector, defaultMemoize } from 'reselect'; import { DataState, - AdjacentProcessMap, Vector2, IndexedEntity, IndexedEdgeLineSegment, @@ -21,7 +20,7 @@ import { isTerminatedProcess, uniquePidForProcess, } from '../../models/process_event'; -import { factory as indexedProcessTreeFactory } from '../../models/indexed_process_tree'; +import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; import { isEqual } from '../../models/aabb'; import { @@ -107,7 +106,7 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in graphableProcesses /* eslint-enable no-shadow */ ) { - return indexedProcessTreeFactory(graphableProcesses); + return indexedProcessTreeModel.factory(graphableProcesses); }); /** @@ -170,27 +169,6 @@ export function relatedEventsReady(data: DataState): Map { return data.relatedEventsReady; } -export const processAdjacencies = createSelector( - indexedProcessTree, - graphableProcesses, - function selectProcessAdjacencies( - /* eslint-disable no-shadow */ - indexedProcessTree, - graphableProcesses - /* eslint-enable no-shadow */ - ) { - const processToAdjacencyMap = new Map(); - const { idToAdjacent } = indexedProcessTree; - - for (const graphableProcess of graphableProcesses) { - const processPid = uniquePidForProcess(graphableProcess); - const adjacencyMap = idToAdjacent.get(processPid)!; - processToAdjacencyMap.set(graphableProcess, adjacencyMap); - } - return { processToAdjacencyMap }; - } -); - /** * `true` if there were more children than we got in the last request. */ @@ -230,7 +208,7 @@ export const relatedEventInfoByEntityId: ( ) { if (!relatedEventsStats) { // If there are no related event stats, there are no related event info objects - return (entityId: string) => null; + return () => null; } return (entityId) => { const stats = relatedEventsStats.get(entityId); @@ -334,7 +312,8 @@ export function databaseDocumentIDToFetch(state: DataState): string | null { return null; } } -export const processNodePositionsAndEdgeLineSegments = createSelector( + +export const layout = createSelector( indexedProcessTree, function processNodePositionsAndEdgeLineSegments( /* eslint-disable no-shadow */ @@ -345,9 +324,62 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( } ); -const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( - processNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments({ +/** + * Given a nodeID (aka entity_id) get the indexed process event. + * Legacy functions take process events instead of nodeID, use this to get + * process events for them. + */ +export const processEventForID: ( + state: DataState +) => (nodeID: string) => ResolverEvent | null = createSelector( + indexedProcessTree, + (tree) => (nodeID: string) => indexedProcessTreeModel.processEvent(tree, nodeID) +); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: (state: DataState) => (nodeID: string) => number | null = createSelector( + layout, + processEventForID, + ({ ariaLevels }, processEventGetter) => (nodeID: string) => { + const node = processEventGetter(nodeID); + return node ? ariaLevels.get(node) ?? null : null; + } +); + +/** + * Returns the following sibling if there is one, or `null`. + */ +export const followingSibling: ( + state: DataState +) => (nodeID: string) => string | null = createSelector( + indexedProcessTree, + processEventForID, + (tree, eventGetter) => { + return (nodeID: string) => { + const event = eventGetter(nodeID); + + // event not found + if (event === null) { + return null; + } + const nextSibling = indexedProcessTreeModel.nextSibling(tree, event); + + // next sibling not found + if (nextSibling === undefined) { + return null; + } + + // return the node ID + return uniquePidForProcess(nextSibling); + }; + } +); + +const spatiallyIndexedLayout: (state: DataState) => rbush = createSelector( + layout, + function ({ /* eslint-disable no-shadow */ processNodePositions, edgeLineSegments, @@ -394,47 +426,46 @@ const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( } ); -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments(tree) { - // memoize the results of this call to avoid unnecessarily rerunning - let lastBoundingBox: AABB | null = null; - let currentlyVisible: VisibleEntites = { - processNodePositions: new Map(), - connectingEdgeLineSegments: [], - }; - return (boundingBox: AABB) => { - if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { - return currentlyVisible; - } else { - const { - minimum: [minX, minY], - maximum: [maxX, maxY], - } = boundingBox; - const entities = tree.search({ - minX, - minY, - maxX, - maxY, - }); - const visibleProcessNodePositions = new Map( - entities - .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') - .map((node) => [node.entity, node.position]) - ); - const connectingEdgeLineSegments = entities - .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') - .map((node) => node.entity); - currentlyVisible = { - processNodePositions: visibleProcessNodePositions, - connectingEdgeLineSegments, - }; - lastBoundingBox = boundingBox; - return currentlyVisible; - } - }; - } -); +export const nodesAndEdgelines: ( + state: DataState +) => (query: AABB) => VisibleEntites = createSelector(spatiallyIndexedLayout, function (tree) { + // memoize the results of this call to avoid unnecessarily rerunning + let lastBoundingBox: AABB | null = null; + let currentlyVisible: VisibleEntites = { + processNodePositions: new Map(), + connectingEdgeLineSegments: [], + }; + return (boundingBox: AABB) => { + if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { + return currentlyVisible; + } else { + const { + minimum: [minX, minY], + maximum: [maxX, maxY], + } = boundingBox; + const entities = tree.search({ + minX, + minY, + maxX, + maxY, + }); + const visibleProcessNodePositions = new Map( + entities + .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') + .map((node) => [node.entity, node.position]) + ); + const connectingEdgeLineSegments = entities + .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') + .map((node) => node.entity); + currentlyVisible = { + processNodePositions: visibleProcessNodePositions, + connectingEdgeLineSegments, + }; + lastBoundingBox = boundingBox; + return currentlyVisible; + } + }; +}); /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index eb2b402a694a5..e91c455c9445f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -9,7 +9,7 @@ import { ResolverAction } from '../actions'; import { resolverReducer } from '../reducer'; import { ResolverState } from '../../types'; import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; -import { visibleProcessNodePositionsAndEdgeLineSegments } from '../selectors'; +import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; @@ -119,15 +119,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should only include 2 nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(2); }); it('the visibleEdgeLineSegments list should only include one edge line', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(1); }); }); @@ -151,15 +147,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should include all process nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(5); }); it('the visibleEdgeLineSegments list include all lines', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(4); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/methods.ts b/x-pack/plugins/security_solution/public/resolver/store/methods.ts index 3890770259156..ad06ddf36161a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/methods.ts @@ -5,7 +5,7 @@ */ import { animatePanning } from './camera/methods'; -import { processNodePositionsAndEdgeLineSegments } from './selectors'; +import { layout } from './selectors'; import { ResolverState } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; @@ -19,7 +19,7 @@ export function animateProcessIntoView( startTime: number, process: ResolverEvent ): ResolverState { - const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); + const { processNodePositions } = layout(state); const position = processNodePositions.get(process); if (position) { return { diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts new file mode 100644 index 0000000000000..ba4a5a169c549 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -0,0 +1,259 @@ +/* + * 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 { ResolverState } from '../types'; +import { createStore } from 'redux'; +import { ResolverAction } from './actions'; +import { resolverReducer } from './reducer'; +import * as selectors from './selectors'; +import { EndpointEvent, ResolverEvent, ResolverTree } from '../../../common/endpoint/types'; + +describe('resolver selectors', () => { + const actions: ResolverAction[] = []; + + /** + * Get state, given an ordered collection of actions. + */ + const state: () => ResolverState = () => { + const store = createStore(resolverReducer); + for (const action of actions) { + store.dispatch(action); + } + return store.getState(); + }; + describe('ariaFlowtoNodeID', () => { + describe('with a tree with no descendants and 2 ancestors', () => { + const originID = 'c'; + const firstAncestorID = 'b'; + const secondAncestorID = 'a'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, + }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const size = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [size, size], + }); + }); + it('should return no flowto for the second ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null); + }); + it('should return no flowto for the first ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + }); + }); + describe('with a tree with 2 children and no ancestors', () => { + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + it('should return the second child as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID); + }); + it('should return no flowto for second child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null); + }); + }); + describe('when only the origin and first child are in view', () => { + beforeEach(() => { + // set the raster size + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + + // get the layout + const layout = selectors.layout(state()); + + // find the position of the second child + const secondChild = selectors.processEventForID(state())(secondChildID); + const positionOfSecondChild = layout.processNodePositions.get(secondChild!)!; + + // the child is indexed by an AABB that extends -720/2 to the left + const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2; + + // adjust the camera so that it doesn't quite see the second child + actions.push({ + // set the position of the camera so that the left edge of the second child is at the right edge + // of the viewable area + type: 'userSetPositionOfCamera', + payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0], + }); + }); + it('the origin should be in view', () => { + const origin = selectors.processEventForID(state())(originID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(origin) + ).toBe(true); + }); + it('the first child should be in view', () => { + const firstChild = selectors.processEventForID(state())(firstChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(firstChild) + ).toBe(true); + }); + it('the second child should not be in view', () => { + const secondChild = selectors.processEventForID(state())(secondChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(secondChild) + ).toBe(false); + }); + it('should return nothing as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null); + }); + }); + }); + }); +}); +/** + * Simple mock endpoint event that works for tree layouts. + */ +function mockEndpointEvent({ + entityID, + name, + parentEntityId, + timestamp, +}: { + entityID: string; + name: string; + parentEntityId: string; + timestamp: number; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + type: 'start', + category: 'process', + }, + process: { + entity_id: entityID, + name, + parent: { + entity_id: parentEntityId, + }, + }, + } as EndpointEvent; +} + +function treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, +}: { + secondAncestorID: string; + firstAncestorID: string; + originID: string; +}): ResolverTree { + const secondAncestor: ResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + name: 'a', + parentEntityId: 'none', + timestamp: 0, + }); + const firstAncestor: ResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + name: 'b', + parentEntityId: secondAncestorID, + timestamp: 1, + }); + const originEvent: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: firstAncestorID, + timestamp: 2, + }); + return ({ + entityID: originID, + children: { + childNodes: [], + }, + ancestry: { + ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], + }, + lifecycle: [originEvent], + } as unknown) as ResolverTree; +} + +function treeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, +}: { + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: 'none', + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: 'd', + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: 'e', + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], + }, + ancestry: { + ancestors: [], + }, + lifecycle: [origin], + } as unknown) as ResolverTree; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 945b2bfed3cfb..ff2179dc3a2ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createSelector } from 'reselect'; +import { createSelector, defaultMemoize } from 'reselect'; import * as cameraSelectors from './camera/selectors'; import * as dataSelectors from './data/selectors'; import * as uiSelectors from './ui/selectors'; -import { ResolverState } from '../types'; +import { ResolverState, IsometricTaxiLayout } from '../types'; +import { uniquePidForProcess } from '../models/process_event'; +import { ResolverEvent } from '../../../common/endpoint/types'; /** * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. @@ -51,9 +53,24 @@ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelecto */ export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating); -export const processNodePositionsAndEdgeLineSegments = composeSelectors( +/** + * Given a nodeID (aka entity_id) get the indexed process event. + * Legacy functions take process events instead of nodeID, use this to get + * process events for them. + */ +export const processEventForID: ( + state: ResolverState +) => (nodeID: string) => ResolverEvent | null = composeSelectors( dataStateSelector, - dataSelectors.processNodePositionsAndEdgeLineSegments + dataSelectors.processEventForID +); + +/** + * The position of nodes and edges. + */ +export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSelectors( + dataStateSelector, + dataSelectors.layout ); /** @@ -74,11 +91,6 @@ export const resolverComponentInstanceID = composeSelectors( dataSelectors.resolverComponentInstanceID ); -export const processAdjacencies = composeSelectors( - dataStateSelector, - dataSelectors.processAdjacencies -); - export const terminatedProcesses = composeSelectors( dataStateSelector, dataSelectors.terminatedProcesses @@ -212,10 +224,8 @@ function composeSelectors( } const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox); -const indexedProcessNodesAndEdgeLineSegments = composeSelectors( - dataStateSelector, - dataSelectors.visibleProcessNodePositionsAndEdgeLineSegments -); + +const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines); /** * Total count of related events for a process. @@ -230,15 +240,50 @@ export const relatedEventTotalForProcess = composeSelectors( * The bounding box represents what the camera can see. The camera position is a function of time because it can be * animated. So in order to get the currently visible entities, we need to pass in time. */ -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodesAndEdgeLineSegments, - boundingBox, - function ( - /* eslint-disable no-shadow */ - indexedProcessNodesAndEdgeLineSegments, - boundingBox - /* eslint-enable no-shadow */ - ) { - return (time: number) => indexedProcessNodesAndEdgeLineSegments(boundingBox(time)); +export const visibleNodesAndEdgeLines = createSelector(nodesAndEdgelines, boundingBox, function ( + /* eslint-disable no-shadow */ + nodesAndEdgelines, + boundingBox + /* eslint-enable no-shadow */ +) { + return (time: number) => nodesAndEdgelines(boundingBox(time)); +}); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: ( + state: ResolverState +) => (nodeID: string) => number | null = composeSelectors( + dataStateSelector, + dataSelectors.ariaLevel +); + +/** + * Takes a nodeID (aka entity_id) and returns the node ID of the node that aria should 'flowto' or null + * If the node has a following sibling that is currently visible, that will be returned, otherwise null. + */ +export const ariaFlowtoNodeID: ( + state: ResolverState +) => (time: number) => (nodeID: string) => string | null = createSelector( + visibleNodesAndEdgeLines, + composeSelectors(dataStateSelector, dataSelectors.followingSibling), + (visibleNodesAndEdgeLinesAtTime, followingSibling) => { + return defaultMemoize((time: number) => { + // get the visible nodes at `time` + const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time); + + // get a `Set` containing their node IDs + const nodesVisibleAtTime: Set = new Set( + [...processNodePositions.keys()].map(uniquePidForProcess) + ); + + // return the ID of `nodeID`'s following sibling, if it is visible + return (nodeID: string): string | null => { + const sibling: string | null = followingSibling(nodeID); + + return sibling === null || nodesVisibleAtTime.has(sibling) === false ? null : sibling; + }; + }); } ); diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 064634472bbbe..0272de0d8fd2a 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -269,38 +269,18 @@ export interface ProcessEvent { }; } -/** - * A map of Process Ids that indicate which processes are adjacent to a given process along - * directions in two axes: up/down and previous/next. - */ -export interface AdjacentProcessMap { - readonly self: string; - parent: string | null; - firstChild: string | null; - previousSibling: string | null; - nextSibling: string | null; - /** - * To support aria-level, this must be >= 1 - */ - level: number; -} - /** * A represention of a process tree with indices for O(1) access to children and values by id. */ export interface IndexedProcessTree { /** - * Map of ID to a process's children + * Map of ID to a process's ordered children */ idToChildren: Map; /** * Map of ID to process */ idToProcess: Map; - /** - * Map of ID to adjacent processes - */ - idToAdjacent: Map; } /** @@ -454,4 +434,9 @@ export interface IsometricTaxiLayout { * A map of edgline segments, which graphically connect nodes. */ edgeLineSegments: EdgeLineSegment[]; + + /** + * defines the aria levels for nodes. + */ + ariaLevels: Map; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 000bf23c5f49d..b366e2f220652 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -53,10 +53,13 @@ export const ResolverMap = React.memo(function ({ useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); + + // use this for the entire render in order to keep things in sync + const timeAtRender = timestamp(); + const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleProcessNodePositionsAndEdgeLineSegments - )(timestamp()); - const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + selectors.visibleNodesAndEdgeLines + )(timeAtRender); const relatedEventsStats = useSelector(selectors.relatedEventsStats); const terminatedProcesses = useSelector(selectors.terminatedProcesses); const { projectionMatrix, ref, onMouseDown } = useCamera(); @@ -100,24 +103,19 @@ export const ResolverMap = React.memo(function ({ /> ))} {[...processNodePositions].map(([processEvent, position]) => { - const adjacentNodeMap = processToAdjacencyMap.get(processEvent); const processEntityId = entityId(processEvent); - if (!adjacentNodeMap) { - // This should never happen - throw new Error('Issue calculating adjacency node map.'); - } return ( ); })} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index 0ed677885775f..6f9bfad8c08c2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -146,7 +146,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ [pushToQueryParams, handleBringIntoViewClick, isProcessOrigin, isProcessTerminated] ); - const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); + const { processNodePositions } = useSelector(selectors.layout); const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map((processEvent) => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx index 0878ead72b2a4..9a477fd998bb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -38,7 +38,6 @@ interface MatchingEventEntry { eventType: string; eventCategory: string; name: { subject: string; descriptor?: string }; - entityId: string; setQueryParams: () => void; } @@ -202,9 +201,11 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr eventCategory: `${eventType}`, eventType: `${event.ecsEventType(resolverEvent)}`, name: event.descriptiveName(resolverEvent), - entityId, setQueryParams: () => { - pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId }); + pushToQueryParams({ + crumbId: entityId === undefined ? '' : String(entityId), + crumbEvent: processEntityId, + }); }, }; }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index e20f06ccf0f72..7666d1ac7c88a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -12,11 +12,12 @@ import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap } from '../types'; +import { Vector2, Matrix3 } from '../types'; import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; +import * as processEventModel from '../models/process_event'; import * as selectors from '../store/selectors'; import { useResolverQueryParams } from './use_resolver_query_params'; @@ -70,10 +71,10 @@ const UnstyledProcessEventDot = React.memo( position, event, projectionMatrix, - adjacentNodeMap, isProcessTerminated, isProcessOrigin, relatedEventsStatsForProcess, + timeAtRender, }: { /** * A `className` string provided by `styled` @@ -91,10 +92,6 @@ const UnstyledProcessEventDot = React.memo( * projectionMatrix which can be used to convert `position` to screen coordinates. */ projectionMatrix: Matrix3; - /** - * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions - */ - adjacentNodeMap: AdjacentProcessMap; /** * Whether or not to show the process as terminated. */ @@ -109,7 +106,16 @@ const UnstyledProcessEventDot = React.memo( * Statistics for the number of related events and alerts for this process node */ relatedEventsStatsForProcess?: ResolverNodeStats; + + /** + * The time (unix epoch) at render. + */ + timeAtRender: number; }) => { + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + // This should be unique to each instance of Resolver + const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`; + /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ @@ -118,12 +124,22 @@ const UnstyledProcessEventDot = React.memo( const [xScale] = projectionMatrix; // Node (html id=) IDs - const selfId = adjacentNodeMap.self; const activeDescendantId = useSelector(selectors.uiActiveDescendantId); const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); + const nodeID = processEventModel.uniquePidForProcess(event); - // Entity ID of self - const selfEntityId = eventModel.entityId(event); + // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. + // this is used to link nodes via aria attributes + const nodeHTMLID = useCallback((id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`), [ + htmlIDPrefix, + ]); + + const ariaLevel: number | null = useSelector(selectors.ariaLevel)(nodeID); + + // the node ID to 'flowto' + const ariaFlowtoNodeID: string | null = useSelector(selectors.ariaFlowtoNodeID)(timeAtRender)( + nodeID + ); const isShowingEventActions = xScale > 0.8; const isShowingDescriptionText = xScale >= 0.55; @@ -204,16 +220,10 @@ const UnstyledProcessEventDot = React.memo( strokeColor, } = cubeAssetsForNode(isProcessTerminated, isProcessOrigin); - const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []); + const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`); - const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [ - resolverNodeIdGenerator, - selfId, - ]); - const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const isActiveDescendant = nodeId === activeDescendantId; - const isSelectedDescendant = nodeId === selectedDescendantId; + const isAriaCurrent = nodeID === activeDescendantId; + const isAriaSelected = nodeID === selectedDescendantId; const dispatch = useResolverDispatch(); @@ -221,34 +231,35 @@ const UnstyledProcessEventDot = React.memo( dispatch({ type: 'userFocusedOnResolverNode', payload: { - nodeId, + nodeId: nodeHTMLID(nodeID), }, }); - }, [dispatch, nodeId]); + }, [dispatch, nodeHTMLID, nodeID]); const handleRelatedEventRequest = useCallback(() => { dispatch({ type: 'userRequestedRelatedEventData', - payload: selfId, + payload: nodeID, }); - }, [dispatch, selfId]); + }, [dispatch, nodeID]); const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { + // This works but the types are missing in the typescript DOM lib // eslint-disable-next-line @typescript-eslint/no-explicit-any (animationTarget.current as any).beginElement(); } dispatch({ type: 'userSelectedResolverNode', payload: { - nodeId, - selectedProcessId: selfId, + nodeId: nodeHTMLID(nodeID), + selectedProcessId: nodeID, }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' }); - }, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]); + pushToQueryParams({ crumbId: nodeID, crumbEvent: 'all' }); + }, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]); /** * Enumerates the stats for related events to display with the node as options, @@ -280,12 +291,12 @@ const UnstyledProcessEventDot = React.memo( }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); + pushToQueryParams({ crumbId: nodeID, crumbEvent: category }); }, }); } return relatedStatsList; - }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, selfEntityId]); + }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, nodeID]); const relatedEventStatusOrOptions = !relatedEventsStatsForProcess ? subMenuAssets.initialMenuStatus @@ -302,15 +313,14 @@ const UnstyledProcessEventDot = React.memo( data-test-subj={'resolverNode'} className={`${className} kbn-resetFocusState`} role="treeitem" - aria-level={adjacentNodeMap.level} - aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling} - aria-labelledby={labelId} - aria-describedby={descriptionId} - aria-haspopup={'true'} - aria-current={isActiveDescendant ? 'true' : undefined} - aria-selected={isSelectedDescendant ? 'true' : undefined} + aria-level={ariaLevel === null ? undefined : ariaLevel} + aria-flowto={ariaFlowtoNodeID === null ? undefined : nodeHTMLID(ariaFlowtoNodeID)} + aria-labelledby={labelHTMLID} + aria-haspopup="true" + aria-current={isAriaCurrent ? 'true' : undefined} + aria-selected={isAriaSelected ? 'true' : undefined} style={nodeViewportStyle} - id={nodeId} + id={nodeHTMLID(nodeID)} tabIndex={-1} >

= 2 ? 'euiButton' : 'euiButton euiButton--small'} - data-test-subject="nodeLabel" - id={labelId} + id={labelHTMLID} onClick={handleClick} onFocus={handleFocus} tabIndex={-1} @@ -386,9 +395,7 @@ const UnstyledProcessEventDot = React.memo( > { throw new Error('failed to create tree'); } const processes: ResolverEvent[] = [ - ...selectors - .processNodePositionsAndEdgeLineSegments(store.getState()) - .processNodePositions.keys(), + ...selectors.layout(store.getState()).processNodePositions.keys(), ]; process = processes[processes.length - 1]; if (!process) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 70baef5fa88ea..3c342ae575aa0 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -20,8 +20,8 @@ export function useResolverQueryParams() { const history = useHistory(); const urlSearch = useLocation().search; const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; - const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`; + const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`; const pushToQueryParams = useCallback( (newCrumbs: CrumbInfo) => { // Construct a new set of params from the current set (minus empty params) @@ -51,9 +51,15 @@ export function useResolverQueryParams() { const parsed = querystring.parse(urlSearch.slice(1)); const crumbEvent = parsed[uniqueCrumbEventKey]; const crumbId = parsed[uniqueCrumbIdKey]; + function valueForParam(param: string | string[]): string { + if (Array.isArray(param)) { + return param[0] || ''; + } + return param || ''; + } return { - crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, - crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + crumbEvent: valueForParam(crumbEvent), + crumbId: valueForParam(crumbId), }; }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts index 2b107ab1b6db4..150f56cbd70cc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts @@ -61,7 +61,7 @@ export class PaginationBuilder { const lastResult = results[results.length - 1]; const cursor = { timestamp: lastResult['@timestamp'], - eventID: eventId(lastResult), + eventID: eventId(lastResult) === undefined ? '' : String(eventId(lastResult)), }; return PaginationBuilder.urlEncodeCursor(cursor); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts index 654ace290f85f..ea52aecb379fa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -32,6 +32,7 @@ export const filterEventsAgainstList = async ({ buildRuleMessage, }: FilterEventsAgainstList): Promise => { try { + logger.debug(buildRuleMessage(`exceptionsList: ${JSON.stringify(exceptionsList, null, 2)}`)); if (exceptionsList == null || exceptionsList.length === 0) { logger.debug(buildRuleMessage('about to return original search result')); return eventSearchResult; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index cd6beb9c68ab2..2a0e39cbbf237 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -91,7 +91,7 @@ export const searchAfterAndBulkCreate = async ({ }; let sortId; // tells us where to start our next search_after query - let signalsCreatedCount = 0; + let searchResultSize = 0; /* The purpose of `maxResults` is to ensure we do not perform @@ -127,8 +127,8 @@ export const searchAfterAndBulkCreate = async ({ toReturn.success = false; return toReturn; } - signalsCreatedCount = 0; - while (signalsCreatedCount < tuple.maxSignals) { + searchResultSize = 0; + while (searchResultSize < tuple.maxSignals) { try { logger.debug(buildRuleMessage(`sortIds: ${sortId}`)); const { @@ -167,6 +167,7 @@ export const searchAfterAndBulkCreate = async ({ searchResult.hits.hits[searchResult.hits.hits.length - 1]?._source['@timestamp'] ) : null; + searchResultSize += searchResult.hits.hits.length; // filter out the search results that match with the values found in the list. // the resulting set are valid signals that are not on the allowlist. @@ -186,14 +187,6 @@ export const searchAfterAndBulkCreate = async ({ break; } - // make sure we are not going to create more signals than maxSignals allows - if (signalsCreatedCount + filteredEvents.hits.hits.length > tuple.maxSignals) { - filteredEvents.hits.hits = filteredEvents.hits.hits.slice( - 0, - tuple.maxSignals - signalsCreatedCount - ); - } - const { bulkCreateDuration: bulkDuration, createdItemsCount: createdCount, @@ -218,7 +211,6 @@ export const searchAfterAndBulkCreate = async ({ }); logger.debug(buildRuleMessage(`created ${createdCount} signals`)); toReturn.createdSignalsCount += createdCount; - signalsCreatedCount += createdCount; if (bulkDuration) { toReturn.bulkCreateTimes.push(bulkDuration); } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx index 4686ede7bc2c2..2b5ffa27e0f82 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_clone/clone_button.tsx @@ -7,7 +7,7 @@ import React, { FC, useContext } from 'react'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { createCapabilityFailureMessage, @@ -25,7 +25,7 @@ export const CloneButton: FC = ({ itemId }) => { const { canCreateTransform } = useContext(AuthorizationContext).capabilities; - const buttonCloneText = i18n.translate('xpack.transform.transformList.cloneActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.cloneActionName', { defaultMessage: 'Clone', }); @@ -33,27 +33,30 @@ export const CloneButton: FC = ({ itemId }) => { history.push(`/${SECTION_SLUG.CLONE_TRANSFORM}/${itemId}`); } - const cloneButton = ( - - {buttonCloneText} - + {buttonText} + ); - if (!canCreateTransform) { - const content = createCapabilityFailureMessage('canStartStopTransform'); - + if (buttonDisabled) { return ( - - {cloneButton} + + {button} ); } - return <>{cloneButton}; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap index 3980cc5d5a1ae..7e98fc90cfad4 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/__snapshots__/delete_button.test.tsx.snap @@ -6,17 +6,17 @@ exports[`Transform: Transform List Actions Minimal initializati delay="regular" position="top" > - - - Delete - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx index b81c3ebc34ca0..2ca48ed734c7f 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_delete/delete_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; import { AuthorizationContext, @@ -29,7 +29,7 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli const disabled = items.some(transformCanNotBeDeleted); const { canDeleteTransform } = useContext(AuthorizationContext).capabilities; - const buttonDeleteText = i18n.translate('xpack.transform.transformList.deleteActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.deleteActionName', { defaultMessage: 'Delete', }); const bulkDeleteButtonDisabledText = i18n.translate( @@ -46,16 +46,20 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli ); const buttonDisabled = forceDisable === true || disabled || !canDeleteTransform; - let deleteButton = ( - onClick(items)} - aria-label={buttonDeleteText} + flush="left" + iconType="trash" + isDisabled={buttonDisabled} + onClick={() => onClick(items)} + size="s" > - {buttonDeleteText} - + {buttonText} + ); if (disabled || !canDeleteTransform) { @@ -66,12 +70,12 @@ export const DeleteButton: FC = ({ items, forceDisable, onCli content = createCapabilityFailureMessage('canDeleteTransform'); } - deleteButton = ( + return ( - {deleteButton} + {button} ); } - return deleteButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx index 6ba8e7aeba20f..40c27cff1e398 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_edit/edit_button.tsx @@ -8,7 +8,7 @@ import React, { useContext, FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { createCapabilityFailureMessage, @@ -21,31 +21,34 @@ interface EditButtonProps { export const EditButton: FC = ({ onClick }) => { const { canCreateTransform } = useContext(AuthorizationContext).capabilities; - const buttonEditText = i18n.translate('xpack.transform.transformList.editActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.editActionName', { defaultMessage: 'Edit', }); - const editButton = ( - - {buttonEditText} - + {buttonText} + ); if (!canCreateTransform) { - const content = createCapabilityFailureMessage('canStartStopTransform'); - return ( - - {editButton} + + {button} ); } - return editButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap index 231a1f30f2c31..d8184773e16b5 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/__snapshots__/start_button.test.tsx.snap @@ -6,15 +6,17 @@ exports[`Transform: Transform List Actions Minimal initializatio delay="regular" position="top" > - - - Start - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx index a0fe1bfbb9544..60f899adc5fb2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_start/start_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; @@ -25,7 +25,7 @@ export const StartButton: FC = ({ items, forceDisable, onClick const { canStartStopTransform } = useContext(AuthorizationContext).capabilities; const isBulkAction = items.length > 1; - const buttonStartText = i18n.translate('xpack.transform.transformList.startActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.startActionName', { defaultMessage: 'Start', }); @@ -84,23 +84,30 @@ export const StartButton: FC = ({ items, forceDisable, onClick } } - const disabled = forceDisable === true || actionIsDisabled; + const buttonDisabled = forceDisable === true || actionIsDisabled; - const startButton = ( - onClick(items)} + flush="left" + iconType="play" + isDisabled={buttonDisabled} + onClick={() => onClick(items)} + size="s" > - {buttonStartText} - + {buttonText} + ); - if (disabled && content !== undefined) { + + if (buttonDisabled && content !== undefined) { return ( - {startButton} + {button} ); } - return startButton; + + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap index dd81bf34bf582..0052dc6254789 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/__snapshots__/stop_button.test.tsx.snap @@ -6,17 +6,17 @@ exports[`Transform: Transform List Actions Minimal initialization delay="regular" position="top" > - - - Stop - + `; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx index 2c67ea3e83ecc..3c5e4323cc69a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_stop/stop_button.tsx @@ -6,7 +6,7 @@ import React, { FC, useContext } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { TRANSFORM_STATE } from '../../../../../../common'; @@ -25,7 +25,7 @@ export const StopButton: FC = ({ items, forceDisable }) => { const isBulkAction = items.length > 1; const { canStartStopTransform } = useContext(AuthorizationContext).capabilities; const stopTransforms = useStopTransforms(); - const buttonStopText = i18n.translate('xpack.transform.transformList.stopActionName', { + const buttonText = i18n.translate('xpack.transform.transformList.stopActionName', { defaultMessage: 'Stop', }); @@ -56,18 +56,24 @@ export const StopButton: FC = ({ items, forceDisable }) => { stopTransforms(items); }; - const disabled = forceDisable === true || !canStartStopTransform || stoppedTransform === true; + const buttonDisabled = + forceDisable === true || !canStartStopTransform || stoppedTransform === true; - const stopButton = ( - - {buttonStopText} - + {buttonText} + ); + if (!canStartStopTransform || stoppedTransform) { return ( = ({ items, forceDisable }) => { : stoppedTransformMessage } > - {stopButton} + {button} ); } - return stopButton; + return button; }; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index 9df4113fa9a8b..a31251943061a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -292,7 +292,7 @@ export const TransformList: FC = ({ button={buttonIcon} isOpen={isActionsMenuOpen} closePopover={() => setIsActionsMenuOpen(false)} - panelPaddingSize="none" + panelPaddingSize="s" anchorPosition="rightUp" > {bulkActionMenuItems} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a4100ae914b25..71fe63e0dbf6b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9588,7 +9588,6 @@ "xpack.ml.dataframe.analyticsList.title": "分析ジョブ", "xpack.ml.dataframe.analyticsList.type": "タイプ", "xpack.ml.dataframe.analyticsList.viewActionName": "表示", - "xpack.ml.dataframe.analyticsList.viewAriaLabel": "表示", "xpack.ml.dataframe.stepCreateForm.createDataFrameAnalyticsSuccessMessage": "データフレーム分析 {jobId} の作成リクエストが受け付けられました。", "xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink": "インデックス名の制限に関する詳細。", "xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel": "データフレーム分析", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 69e37f3f9f9f0..35f3e35bdeae8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9593,7 +9593,6 @@ "xpack.ml.dataframe.analyticsList.title": "分析作业", "xpack.ml.dataframe.analyticsList.type": "类型", "xpack.ml.dataframe.analyticsList.viewActionName": "查看", - "xpack.ml.dataframe.analyticsList.viewAriaLabel": "查看", "xpack.ml.dataframe.stepCreateForm.createDataFrameAnalyticsSuccessMessage": "数据帧分析 {jobId} 创建请求已确认。", "xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink": "详细了解索引名称限制。", "xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel": "数据帧分析", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 3727d80eb2d1f..216e6967833b2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -74,6 +74,7 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionSecrets={() => {}} docLinks={deps!.docLinks} readOnly={false} + consumer={'case'} /> ); expect(wrapper.find('[data-test-subj="case-servicenow-mappings"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 0b377d55f9681..f99a276305d75 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -12,9 +12,11 @@ import { EuiFormRow, EuiFieldPassword, EuiSpacer, + EuiLink, } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; import { ActionConnectorFieldsProps } from '../../../../types'; import * as i18n from './translations'; import { ServiceNowActionConnector, CasesConfigurationMapping } from './types'; @@ -23,7 +25,7 @@ import { FieldMapping } from './case_mappings/field_mapping'; const ServiceNowConnectorFields: React.FC> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => { +>> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, docLinks }) => { // TODO: remove incidentConfiguration later, when Case ServiceNow will move their fields to the level of action execution const { apiUrl, incidentConfiguration, isCaseOwned } = action.config; const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; @@ -79,6 +81,17 @@ const ServiceNowConnectorFields: React.FC + + + } > - {isCaseOwned && ( // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution + {consumer === 'case' && ( // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index 3ea628cd65473..1fc856b1e1ab2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -39,7 +39,7 @@ describe('ServiceNowParamsFields renders', () => { ); expect(wrapper.find('[data-test-subj="impactSelect"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="incidentDescriptionTextArea"]').length > 0).toBeTruthy(); - expect(wrapper.find('[data-test-subj="incidentCommentTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentTextArea"]').length > 0).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 67070b6dc8907..1e0f4d1fdc57c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -5,17 +5,17 @@ */ import React, { Fragment, useEffect } from 'react'; -import { EuiFormRow, EuiTextArea } from '@elastic/eui'; +import { EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { EuiSelect } from '@elastic/eui'; import { EuiFlexGroup } from '@elastic/eui'; import { EuiFlexItem } from '@elastic/eui'; -import { EuiFieldText } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; import { EuiTitle } from '@elastic/eui'; import { ActionParamsProps } from '../../../../types'; -import { AddMessageVariables } from '../../add_message_variables'; import { ServiceNowActionParams } from './types'; +import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; const ServiceNowParamsFields: React.FunctionComponent { - editSubActionProperty( - paramsProperty, - ((actionParams as any).subActionParams[paramsProperty] ?? '').concat(` {{${variable}}}`) - ); - }; - return ( @@ -164,96 +157,44 @@ const ServiceNowParamsFields: React.FunctionComponent onSelectMessageVariable('title', variable)} - paramsProperty="title" - /> - } > - 0 && title !== undefined} - value={title || ''} - onChange={(e: React.ChangeEvent) => { - editSubActionProperty('title', e.target.value); - }} - onBlur={() => { - if (!title) { - editSubActionProperty('title', ''); - } - }} + - - onSelectMessageVariable('description', variable) - } - paramsProperty="description" - /> - } - > - { - editSubActionProperty('description', e.target.value); - }} - onBlur={() => { - if (!description) { - editSubActionProperty('description', ''); - } - }} - /> - - + - onSelectMessageVariable('comment', variable) - } - paramsProperty="comment" - /> - } - > - { - editSubActionProperty('comment', e.target.value); - }} - onBlur={() => { - if (!comment) { - editSubActionProperty('comment', ''); - } - }} - /> - + errors={errors.comment as string[]} + /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 588d267da3484..af10f583dd413 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -46,7 +46,6 @@ import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; import { hasSaveActionsCapability } from '../../lib/capabilities'; -import { ServiceNowConnectorConfiguration } from '../../../common'; interface ActionAccordionFormProps { actions: AlertAction[]; @@ -135,14 +134,7 @@ export const ActionForm = ({ try { setIsLoadingConnectors(true); const loadedConnectors = await loadConnectors({ http }); - setConnectors( - loadedConnectors.filter( - (action) => - action.actionTypeId !== ServiceNowConnectorConfiguration.id || - (action.actionTypeId === ServiceNowConnectorConfiguration.id && - !action.config.isCaseOwned) - ) - ); + setConnectors(loadedConnectors); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index c1939bf6fa07a..837529bfc938d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -21,7 +21,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ServiceNowConnectorConfiguration } from '../../../../common'; import { useAppDependencies } from '../../../app_context'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import ConnectorAddFlyout from '../../action_connector_form/connector_add_flyout'; @@ -120,14 +119,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { setIsLoadingActions(true); try { const actionsResponse = await loadAllActions({ http }); - setActions( - actionsResponse.filter( - (action) => - action.actionTypeId !== ServiceNowConnectorConfiguration.id || - (action.actionTypeId === ServiceNowConnectorConfiguration.id && - !action.config.isCaseOwned) - ) - ); + setActions(actionsResponse); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 79bf4dfa0fe05..26c7bb3b6c125 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -27,8 +27,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - // Failing ES promotion: https://github.com/elastic/kibana/issues/71582 - describe.skip('alerts', () => { + describe('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index 9fdc8a1274d03..390b50acb3705 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -31,8 +31,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { .then((response: SupertestResponse) => response.body); } - // Failing ES promotion: https://github.com/elastic/kibana/issues/71558 - describe.skip('update', () => { + describe('update', () => { const objectRemover = new ObjectRemover(supertest); after(() => objectRemover.removeAll()); diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index ec80b9aed4be0..df81b826132a9 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -5,9 +5,7 @@ */ export default function loadTests({ loadTestFile }) { - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('Fleet Endpoints', () => { + describe('Fleet Endpoints', () => { loadTestFile(require.resolve('./setup')); loadTestFile(require.resolve('./delete_agent')); loadTestFile(require.resolve('./list_agent')); diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index 9f5c2a3de07bf..f8ddca374209b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -51,8 +51,7 @@ export default function ({ getService }: FtrProviderContext) { await deleteComposableIndexTemplate(name); }; - // Temporarily skipping tests until ES snapshot is updated - describe.skip('Data streams', function () { + describe('Data streams', function () { describe('Get', () => { const testDataStreamName = 'test-data-stream'; diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts index 242f906d0d197..3340ac49b2d2d 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts @@ -20,7 +20,8 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('add_prepackaged_rules', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71867 + describe.skip('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { const { body } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index 5e0ce0b824323..7671b1bd49744 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -20,7 +20,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // https://github.com/elastic/kibana/issues/71814 + // FLAKY: https://github.com/elastic/kibana/issues/71814 describe.skip('add_prepackaged_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before adding prepackaged rules', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index 73d39b600cf11..c763be1c2c3ec 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -31,8 +31,7 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const es = getService('es'); - // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 - describe.skip('create_rules', () => { + describe('create_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before creating a rule', async () => { const { body } = await supertest diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 97cdd081705a4..503b275442046 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -64,7 +64,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.getActions().move({ x: 5, y: 5, origin: el._webElement }).click().perform(); } - describe('lens smokescreen tests', () => { + // FLAKY: https://github.com/elastic/kibana/issues/71304 + describe.skip('lens smokescreen tests', () => { it('should allow editing saved visualizations', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('Artistpreviouslyknownaslens'); diff --git a/x-pack/test/functional/apps/management/create_index_pattern_wizard.js b/x-pack/test/functional/apps/management/create_index_pattern_wizard.js new file mode 100644 index 0000000000000..187124f9edc12 --- /dev/null +++ b/x-pack/test/functional/apps/management/create_index_pattern_wizard.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ getService, getPageObjects }) { + const kibanaServer = getService('kibanaServer'); + const es = getService('legacyEs'); + const PageObjects = getPageObjects(['settings', 'common']); + + describe('"Create Index Pattern" wizard', function () { + before(async function () { + // delete .kibana index and then wait for Kibana to re-create it + await kibanaServer.uiSettings.replace({}); + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaIndexPatterns(); + }); + + describe('data streams', () => { + it('can be an index pattern', async () => { + await es.transport.request({ + path: '/_index_template/generic-logs', + method: 'PUT', + body: { + index_patterns: ['logs-*', 'test_data_stream'], + template: { + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + }, + }, + }, + data_stream: {}, + }, + }); + + await es.transport.request({ + path: '/_data_stream/test_data_stream', + method: 'PUT', + }); + + await PageObjects.settings.createIndexPattern('test_data_stream', false); + + await es.transport.request({ + path: '/_data_stream/test_data_stream', + method: 'DELETE', + }); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/management/index.js b/x-pack/test/functional/apps/management/index.js new file mode 100644 index 0000000000000..19c68a2da9d9b --- /dev/null +++ b/x-pack/test/functional/apps/management/index.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function ({ loadTestFile }) { + describe('management', function () { + this.tags(['ciGroup2']); + + loadTestFile(require.resolve('./create_index_pattern_wizard')); + }); +} diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts index 497078c4fd273..d65dfe20e5cd8 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/advanced_job.ts @@ -274,7 +274,8 @@ export default function ({ getService }: FtrProviderContext) { const calendarId = `wizard-test-calendar_${Date.now()}`; - describe('advanced job', function () { + // FLAKY: https://github.com/elastic/kibana/issues/71971 + describe.skip('advanced job', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/ecommerce'); diff --git a/x-pack/test/functional/apps/monitoring/cluster/overview.js b/x-pack/test/functional/apps/monitoring/cluster/overview.js index 94996d6ab40ab..0e608e9a055fa 100644 --- a/x-pack/test/functional/apps/monitoring/cluster/overview.js +++ b/x-pack/test/functional/apps/monitoring/cluster/overview.js @@ -10,8 +10,7 @@ import { getLifecycleMethods } from '../_get_lifecycle_methods'; export default function ({ getService, getPageObjects }) { const overview = getService('monitoringClusterOverview'); - // https://github.com/elastic/kibana/issues/71796 - describe.skip('Cluster overview', () => { + describe('Cluster overview', () => { describe('for Green cluster with Gold license', () => { const { setup, tearDown } = getLifecycleMethods(getService, getPageObjects); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index ad65f82d6dfe1..5c13e430ae2ca 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -57,6 +57,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/remote_clusters'), resolve(__dirname, './apps/transform'), resolve(__dirname, './apps/reporting_management'), + resolve(__dirname, './apps/management'), // This license_management file must be last because it is destructive. resolve(__dirname, './apps/license_management'), diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 4c33a709d9bf9..d86d272c1da8c 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -19,8 +19,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const retry = getService('retry'); const find = getService('find'); - // Failing ES Promotion: https://github.com/elastic/kibana/issues/71559 - describe.skip('Alert Details', function () { + describe('Alert Details', function () { describe('Header', function () { const testRunUuid = uuid.v4(); before(async () => { diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts index f2ca98ca39a0b..b6807b2fd3414 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts @@ -21,8 +21,7 @@ export default function ({ getService }: FtrProviderContext) { const mappingsPackage = 'overrides-0.1.0'; const server = dockerServers.get('registry'); - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 + // FLAKY: https://github.com/elastic/kibana/issues/71939 describe.skip('installs packages that include settings and mappings overrides', async () => { after(async () => { if (server.enabled) { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 0c9a86449506b..cf76f297d83be 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -19,9 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('When on the Endpoint Policy Details Page', function () { + describe('When on the Endpoint Policy Details Page', function () { this.tags(['ciGroup7']); describe('with an invalid policy id', () => { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 5b4a5cca108f9..b91f0647487ff 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -19,8 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const policyTestResources = getService('policyTestResources'); const RELATIVE_DATE_FORMAT = /\d (?:seconds|minutes) ago/i; - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 + // FLAKY: https://github.com/elastic/kibana/issues/71951 describe.skip('When on the Endpoint Policy List', function () { this.tags(['ciGroup7']); before(async () => {