diff --git a/.eslintrc.js b/.eslintrc.js index 09de32a91bca3c..67c52117399cc5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -446,6 +446,7 @@ module.exports = { '!(src|x-pack)/plugins/**/(public|server)/mocks/index.{js,mjs,ts}', '!(src|x-pack)/plugins/**/(public|server)/(index|mocks).{js,mjs,ts,tsx}', '!(src|x-pack)/plugins/**/__stories__/index.{js,mjs,ts,tsx}', + '!(src|x-pack)/plugins/**/__fixtures__/index.{js,mjs,ts,tsx}', ], allowSameFolder: true, errorMessage: 'Plugins may only import from top-level public and server modules.', diff --git a/.i18nrc.json b/.i18nrc.json index 732644b43e1f7c..65ea4d522af5c6 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -18,6 +18,7 @@ "expressions": "src/plugins/expressions", "expressionError": "src/plugins/expression_error", "expressionRevealImage": "src/plugins/expression_reveal_image", + "expressionShape": "src/plugins/expression_shape", "inputControl": "src/plugins/input_control_vis", "inspector": "src/plugins/inspector", "inspectorViews": "src/legacy/core_plugins/inspector_views", diff --git a/docs/dev-tools/console/console.asciidoc b/docs/dev-tools/console/console.asciidoc index 7cef9376330623..731290fea22d0c 100644 --- a/docs/dev-tools/console/console.asciidoc +++ b/docs/dev-tools/console/console.asciidoc @@ -1,7 +1,7 @@ [[console-kibana]] == Console -Console enables you to interact with the REST API of {es}. You can: +*Console* enables you to interact with the REST API of {es}. You can: * Send requests to {es} and view the responses * View API documentation @@ -12,13 +12,13 @@ To get started, open the main menu, click *Dev Tools*, then click *Console*. [role="screenshot"] image::dev-tools/console/images/console.png["Console"] -NOTE: You are unable to interact with the REST API of {kib} with the Console. +NOTE: You cannot to interact with the REST API of {kib} with the Console. [float] [[console-api]] === Write requests -Console understands commands in a cURL-like syntax. +*Console* understands commands in a cURL-like syntax. For example, the following is a `GET` request to the {es} `_search` API. [source,js] @@ -43,8 +43,8 @@ curl -XGET "http://localhost:9200/_search" -d' }' ---------------------------------- -When you paste the command into Console, {kib} automatically converts it -to Console syntax. Alternatively, if you want to see Console syntax in cURL, +When you paste the command into *Console*, {kib} automatically converts it +to *Console* syntax. Alternatively, if you want to see *Console* syntax in cURL, click the action icon (image:dev-tools/console/images/wrench.png[]) and select *Copy as cURL*. Once copied, the username and password will need to be provided for the calls to work from external environments. @@ -53,7 +53,7 @@ for the calls to work from external environments. [[console-autocomplete]] ==== Autocomplete -When you're typing a command, Console makes context-sensitive suggestions. +When you're typing a command, *Console* makes context-sensitive suggestions. These suggestions show you the parameters for each API and speed up your typing. To configure your preferences for autocomplete, go to <>. @@ -69,15 +69,16 @@ and then select *Auto indent*. For example, you might have a request formatted like this: [role="screenshot"] -image::dev-tools/console/images/copy-curl.png["Console close-up"] +image::dev-tools/console/images/copy-curl.png["Console close-up", width=75%] +] -Console adjusts the JSON body of the request to apply the indents. +*Console* adjusts the JSON body of the request to apply the indents. [role="screenshot"] -image::dev-tools/console/images/request.png["Console close-up"] +image::dev-tools/console/images/request.png["Console close-up", width=75%] If you select *Auto indent* on a request that is already well formatted, -Console collapses the request body to a single line per document. +*Console* collapses the request body to a single line per document. This is helpful when working with the {es} {ref}/docs-bulk.html[bulk APIs]. @@ -90,8 +91,9 @@ When you're ready to submit the request to {es}, click the green triangle. You can select multiple requests and submit them together. -Console sends the requests to {es} one by one and shows the output -in the response pane. Submitting multiple request is helpful when you're debugging an issue or trying query +*Console* sends the requests to {es} one by one and shows the output +in the response pane. Submitting multiple requests is helpful +when you're debugging an issue or trying query combinations in multiple scenarios. @@ -107,7 +109,7 @@ the action icon (image:dev-tools/console/images/wrench.png[]) and select [[console-history]] === Get your request history -Console maintains a list of the last 500 requests that {es} successfully executed. +*Console* maintains a list of the last 500 requests that {es} successfully executed. To view your most recent requests, click *History*. If you select a request and click *Apply*, {kib} adds it to the editor at the current cursor position. @@ -115,11 +117,11 @@ and click *Apply*, {kib} adds it to the editor at the current cursor position. [[configuring-console]] === Configure Console settings -You can configure the Console font size, JSON syntax, +You can configure the *Console* font size, JSON syntax, and autocomplete suggestions in *Settings*. [role="screenshot"] -image::dev-tools/console/images/console-settings.png["Console Settings"] +image::dev-tools/console/images/console-settings.png["Console Settings", width=60%] [float] [[keyboard-shortcuts]] @@ -132,7 +134,7 @@ shortcuts, click *Help*. [[console-settings]] === Disable Console -If you don’t want to use Console, you can disable it by setting `console.enabled` +If you don’t want to use *Console*, you can disable it by setting `console.enabled` to `false` in your `kibana.yml` configuration file. Changing this setting causes the server to regenerate assets on the next startup, which might cause a delay before pages start being served. diff --git a/docs/dev-tools/console/images/console.png b/docs/dev-tools/console/images/console.png index 0511ed858d1c3a..88f069388ea676 100644 Binary files a/docs/dev-tools/console/images/console.png and b/docs/dev-tools/console/images/console.png differ diff --git a/docs/dev-tools/console/images/copy-curl.png b/docs/dev-tools/console/images/copy-curl.png index 4811c1d24bfb86..a6fb9cd1438f47 100644 Binary files a/docs/dev-tools/console/images/copy-curl.png and b/docs/dev-tools/console/images/copy-curl.png differ diff --git a/docs/dev-tools/console/images/request.png b/docs/dev-tools/console/images/request.png index a8332434ec1865..c95b54dc95b0ac 100644 Binary files a/docs/dev-tools/console/images/request.png and b/docs/dev-tools/console/images/request.png differ diff --git a/docs/dev-tools/grokdebugger/images/grok-debugger-custom-pattern.png b/docs/dev-tools/grokdebugger/images/grok-debugger-custom-pattern.png index 2cb6f1dbf7226f..2a1660c860b4b4 100644 Binary files a/docs/dev-tools/grokdebugger/images/grok-debugger-custom-pattern.png and b/docs/dev-tools/grokdebugger/images/grok-debugger-custom-pattern.png differ diff --git a/docs/dev-tools/grokdebugger/images/grok-debugger-overview.png b/docs/dev-tools/grokdebugger/images/grok-debugger-overview.png index b6e9b734b307e3..4692c7a8020673 100644 Binary files a/docs/dev-tools/grokdebugger/images/grok-debugger-overview.png and b/docs/dev-tools/grokdebugger/images/grok-debugger-overview.png differ diff --git a/docs/dev-tools/grokdebugger/index.asciidoc b/docs/dev-tools/grokdebugger/index.asciidoc index 82ae724f705f65..934452c54ccca6 100644 --- a/docs/dev-tools/grokdebugger/index.asciidoc +++ b/docs/dev-tools/grokdebugger/index.asciidoc @@ -1,19 +1,19 @@ [role="xpack"] [[xpack-grokdebugger]] -== Debugging grok expressions +== Debug grok expressions You can build and debug grok patterns in the {kib} *Grok Debugger* -before you use them in your data processing pipelines. Grok is a pattern +before you use them in your data processing pipelines. Grok is a pattern matching syntax that you can use to parse arbitrary text and structure it. Grok is good for parsing syslog, apache, and other webserver logs, mysql logs, and in general, any log format that is -written for human consumption. +written for human consumption. Grok patterns are supported in the ingest node {ref}/grok-processor.html[grok processor] and the Logstash -{logstash-ref}/plugins-filters-grok.html[grok filter]. See +{logstash-ref}/plugins-filters-grok.html[grok filter]. See {logstash-ref}/plugins-filters-grok.html#_grok_basics[grok basics] -for more information on the syntax for a grok pattern. +for more information on the syntax for a grok pattern. The Elastic Stack ships with more than 120 reusable grok patterns. See @@ -27,10 +27,10 @@ in ingest node and Logstash. [float] [[grokdebugger-getting-started]] -=== Getting started with the Grok Debugger +=== Get started This example walks you through using the *Grok Debugger*. This tool -is automatically enabled in {kib}. +is automatically enabled in {kib}. NOTE: If you're using {stack-security-features}, you must have the `manage_pipeline` permission to use the Grok Debugger. @@ -66,12 +66,12 @@ image::dev-tools/grokdebugger/images/grok-debugger-overview.png["Grok Debugger"] [float] [[grokdebugger-custom-patterns]] -=== Testing custom patterns +=== Test custom patterns If the default grok pattern dictionary doesn't contain the patterns you need, -you can define, test, and debug custom patterns using the Grok Debugger. +you can define, test, and debug custom patterns using the *Grok Debugger*. -Custom patterns that you enter in the Grok Debugger are not saved. Custom patterns +Custom patterns that you enter in the *Grok Debugger* are not saved. Custom patterns are only available for the current debugging session and have no side effects. Follow this example to define a custom pattern. diff --git a/docs/dev-tools/painlesslab/images/painless-lab.png b/docs/dev-tools/painlesslab/images/painless-lab.png index fbfd54f69954dd..65b4141ed5c547 100644 Binary files a/docs/dev-tools/painlesslab/images/painless-lab.png and b/docs/dev-tools/painlesslab/images/painless-lab.png differ diff --git a/docs/dev-tools/painlesslab/index.asciidoc b/docs/dev-tools/painlesslab/index.asciidoc index 5e329843843ec1..7b4e9101a99013 100644 --- a/docs/dev-tools/painlesslab/index.asciidoc +++ b/docs/dev-tools/painlesslab/index.asciidoc @@ -4,7 +4,7 @@ beta::[] -The Painless Lab is an interactive code editor that lets you test and +The *Painless Lab* is an interactive code editor that lets you test and debug {ref}/modules-scripting-painless.html[Painless scripts] in real-time. You can use the Painless scripting language to create <>, @@ -12,6 +12,7 @@ process {ref}/docs-reindex.html[reindexed data], define complex <>, and work with data in other contexts. -To get started, open the main menu, click *Dev Tools*, then click *Painless Lab*. +To get started, open the main menu, click *Dev Tools*, and then click *Painless Lab*. +[role="screenshot"] image::dev-tools/painlesslab/images/painless-lab.png[Painless Lab] diff --git a/docs/dev-tools/searchprofiler/getting-started.asciidoc b/docs/dev-tools/searchprofiler/getting-started.asciidoc deleted file mode 100644 index ad73d03bcbfd8a..00000000000000 --- a/docs/dev-tools/searchprofiler/getting-started.asciidoc +++ /dev/null @@ -1,49 +0,0 @@ -[role="xpack"] -[[profiler-getting-started]] -=== Getting Started - -The {searchprofiler} is automatically enabled in {kib}. Open the main menu, click *Dev Tools*, then click *{searchprofiler}* -to get started. - -{searchprofiler} displays the names of the indices searched, the shards in each index, -and how long it took for the query to complete. To try it out, replace the default `match_all` query -with the query you want to profile and click *Profile*. - -The following example shows the results of profiling the `match_all` query. -If we take a closer look at the information for the `.kibana_1` sample index, the -Cumulative Time field shows us that the query took 1.279ms to execute. - -[role="screenshot"] -image::dev-tools/searchprofiler/images/overview.png["{searchprofiler} example"] - - -[NOTE] -==== -The Cumulative Time metric is the sum of individual shard times. -It is not necessarily the actual time it took for the query to return (wall clock time). -Because shards might be processed in parallel on multiple nodes, the wall clock time can -be significantly less than the Cumulative Time. However, if shards are colocated on the -same node and executed serially, the wall clock time is closer to the Cumulative Time. - -While the Cumulative Time metric is useful for comparing the performance of your -indices and shards, it doesn't necessarily represent the actual physical query times. -==== - -You can select the name of the shard and then click *View details* to see more profiling information, -including details about the query component(s) that ran on the shard, as well as the timing -breakdown of low-level Lucene methods. For more information, see {ref}/search-profile.html#profiling-queries[Profiling queries]. - -[float] -=== Index and type filtering - -By default, all queries executed by the {searchprofiler} are sent -to `GET /_search`. It searches across your entire cluster (all indices, all types). - -If you need to query a specific index or type (or several), you can use the Index -and Type filters. - -In the following example, the query is executed against the indices `test` and `kibana_1` -and the type `my_type`. This is equivalent making a request to `GET /test,kibana_1/my_type/_search`. - -[role="screenshot"] -image::dev-tools/searchprofiler/images/filter.png["Filtering by index and type"] diff --git a/docs/dev-tools/searchprofiler/gs-index.asciidoc b/docs/dev-tools/searchprofiler/gs-index.asciidoc deleted file mode 100644 index b4f5d48290f5e3..00000000000000 --- a/docs/dev-tools/searchprofiler/gs-index.asciidoc +++ /dev/null @@ -1,20 +0,0 @@ -[role="xpack"] -[[xpack-profiler]] -= Profiling queries and aggregations - -[partintro] --- -{es} has a powerful {ref}/search-profile.html[Profile API] which can be used to inspect and analyze -your search queries. The response returns a large JSON blob, which can be -difficult to analyze manually. - -The {searchprofiler} tool can transform this JSON output -into a visualization that is easy to navigate, allowing you to diagnose and debug -poorly performing queries much faster. - -[role="screenshot"] -image::dev-tools/searchprofiler/images/overview.png["{searchprofiler} Visualization"] - --- - -include::getting-started.asciidoc[] diff --git a/docs/dev-tools/searchprofiler/images/filter.png b/docs/dev-tools/searchprofiler/images/filter.png index a740ec44b9d805..0bcfd7ca5cfad6 100644 Binary files a/docs/dev-tools/searchprofiler/images/filter.png and b/docs/dev-tools/searchprofiler/images/filter.png differ diff --git a/docs/dev-tools/searchprofiler/images/gs10.png b/docs/dev-tools/searchprofiler/images/gs10.png index 6be78b2ce8eb3e..e9a6615f50ac35 100644 Binary files a/docs/dev-tools/searchprofiler/images/gs10.png and b/docs/dev-tools/searchprofiler/images/gs10.png differ diff --git a/docs/dev-tools/searchprofiler/images/gs8.png b/docs/dev-tools/searchprofiler/images/gs8.png index 7ab8389897e4ef..75b93d4dfbdb76 100644 Binary files a/docs/dev-tools/searchprofiler/images/gs8.png and b/docs/dev-tools/searchprofiler/images/gs8.png differ diff --git a/docs/dev-tools/searchprofiler/images/overview.png b/docs/dev-tools/searchprofiler/images/overview.png index 19df1700a5bae1..2669adc13b349a 100644 Binary files a/docs/dev-tools/searchprofiler/images/overview.png and b/docs/dev-tools/searchprofiler/images/overview.png differ diff --git a/docs/dev-tools/searchprofiler/images/pasting.png b/docs/dev-tools/searchprofiler/images/pasting.png deleted file mode 100644 index 466ab9159bfed2..00000000000000 Binary files a/docs/dev-tools/searchprofiler/images/pasting.png and /dev/null differ diff --git a/docs/dev-tools/searchprofiler/images/search-profiler-json.png b/docs/dev-tools/searchprofiler/images/search-profiler-json.png new file mode 100644 index 00000000000000..a81286c9e6cca8 Binary files /dev/null and b/docs/dev-tools/searchprofiler/images/search-profiler-json.png differ diff --git a/docs/dev-tools/searchprofiler/index.asciidoc b/docs/dev-tools/searchprofiler/index.asciidoc index aca96dbfe3ee32..c323427318d549 100644 --- a/docs/dev-tools/searchprofiler/index.asciidoc +++ b/docs/dev-tools/searchprofiler/index.asciidoc @@ -1,20 +1,324 @@ [role="xpack"] [[xpack-profiler]] -== Profiling queries and aggregations +== Profile queries and aggregations -{es} has a powerful {ref}/search-profile.html[Profile API] which can be used to inspect and analyze +{es} has a powerful {ref}/search-profile.html[Profile API] that you can use to inspect and analyze your search queries. The response returns a large JSON blob, which can be difficult to analyze manually. -The {searchprofiler} tool can transform this JSON output +The *{searchprofiler}* tool can transform this JSON output into a visualization that is easy to navigate, allowing you to diagnose and debug poorly performing queries much faster. +[float] +[[search-profiler-getting-started]] +=== Get started -image::dev-tools/searchprofiler/images/overview.png["{searchprofiler} Visualization"] +*{searchprofiler}* is automatically enabled in {kib}. Open the main menu, +click *Dev Tools*, and then click *{searchprofiler}* +to get started. -include::getting-started.asciidoc[] +*{searchprofiler}* displays the names of the indices searched, the shards in each index, +and how long it took for the query to complete. To try it out, replace the default `match_all` query +with the query you want to profile, and then click *Profile*. -include::more-complicated.asciidoc[] +The following example shows the results of profiling the `match_all` query. +If you take a closer look at the information for the `.security_7` sample index, the +*Cumulative time* field shows you that the query took 0.028ms to execute. -include::pasting.asciidoc[] +[role="screenshot"] +image::dev-tools/searchprofiler/images/overview.png["{searchprofiler} visualization"] + + +[NOTE] +==== +The cumulative time metric is the sum of individual shard times. +It is not necessarily the actual time it took for the query to return (wall clock time). +Because shards might be processed in parallel on multiple nodes, the wall clock time can +be significantly less than the cumulative time. However, if shards are colocated on the +same node and executed serially, the wall clock time is closer to the cumulative time. + +While the cumulative time metric is useful for comparing the performance of your +indices and shards, it doesn't necessarily represent the actual physical query times. +==== + +To see more profiling information, click *View details*. You'll +see details about the query components that ran on the shard and the timing +breakdown of low-level methods. For more information, refer to {ref}/search-profile.html#profiling-queries[Profiling queries]. + +[float] +=== Filter for an index or type + +By default, all queries executed by the *{searchprofiler}* are sent +to `GET /_search`. It searches across your entire cluster (all indices, all types). + +To query a specific index or type, you can use the *Index* filter. + +In the following example, the query is executed against the indices `.security-7` and `kibana_sample_data_ecommerce`. +This is equivalent making a request to `GET /test,kibana_1/_search`. + +[role="screenshot"] +image::dev-tools/searchprofiler/images/filter.png["Filtering by index and type"] + +[[profile-complicated-query]] +[float] +=== Profile a more complicated query + +To understand how the query trees are displayed inside the *{searchprofiler}*, +take a look at a more complicated query. + +. Index the following data via *Console*: ++ +-- +[source,js] +-------------------------------------------------- +POST test/_bulk +{"index":{}} +{"name":"aaron","age":23,"hair":"brown"} +{"index":{}} +{"name":"sue","age":19,"hair":"red"} +{"index":{}} +{"name":"sally","age":19,"hair":"blonde"} +{"index":{}} +{"name":"george","age":19,"hair":"blonde"} +{"index":{}} +{"name":"fred","age":69,"hair":"blonde"} +-------------------------------------------------- +// CONSOLE +-- + +. From the *{searchprofiler}*, enter *test* in the *Index* field to restrict profiled +queries to the `test` index. + +. Replace the default `match_all` query in the query editor with a query that has two sub-query +components and includes a simple aggregation: ++ +-- +[source,js] +-------------------------------------------------- +{ + "query": { + "bool": { + "should": [ + { + "match": { + "name": "fred" + } + }, + { + "terms": { + "name": [ + "sue", + "sally" + ] + } + } + ] + } + }, + "aggs": { + "stats": { + "stats": { + "field": "price" + } + } + } +} +-------------------------------------------------- +// NOTCONSOLE +-- + +. Click *Profile* to profile the query and visualize the results. ++ +[role="screenshot"] +image::dev-tools/searchprofiler/images/gs8.png["Profiling the more complicated query"] ++ +- The top `BooleanQuery` component corresponds to the bool in the query. +- The second `BooleanQuery` corresponds to the terms query, which is internally +converted to a `Boolean` of should clauses. It has two child queries that correspond +to "sally" and "sue from the terms query. +- The `TermQuery` that's labeled with "name:fred" corresponds to match: fred in the query. ++ +If you look at the time columns, you can see that *Self time* and *Total time* are no longer +identical on all the rows. *Self time* represents how long the query component took to execute. +*Total time* is the time a query component and all its children took to execute. +Therefore, queries like the Boolean queries often have a larger total time than self time. + +. Click *Aggregation Profile* to view aggregation profiling statistics. ++ +This query includes a `stats` agg on the `"age"` field. +The *Aggregation Profile* tab is only enabled when the query being profiled contains an aggregation. + +. Click *View details* to view the timing breakdown. ++ +[role="screenshot"] +image::dev-tools/searchprofiler/images/gs10.png["Drilling into the first shard's details"] ++ +For more information about how the *{searchprofiler}* works, how timings are calculated, and +how to interpret various results, see +{ref}/search-profile.html#profiling-queries[Profiling queries]. + +[[profiler-render-JSON]] +[float] +=== Render pre-captured profiler JSON + +The *{searchprofiler}* queries the cluster that the {kib} node is attached to. +It does this by executing the query against the cluster and collecting the results. + +Sometimes you might want to investigate performance problems that are temporal in nature. +For example, a query might only be slow at certain time of day when many customers are using your system. +You can set up a process to automatically profile slow queries when they occur and then +save those profile responses for later analysis. + +The *{searchprofiler}* supports this workflow by allowing you to paste the +pre-captured JSON in the query editor. The *{searchprofiler}* will detect that you +have entered a JSON response (rather than a query) and will render just the visualization, +rather than querying the cluster. + +To see how this works, copy and paste the following profile response into the +query editor and click *Profile*. + +[source,js] +-------------------------------------------------- +{ + "took": 3, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "failed": 0 + }, + "hits": { + "total": 1, + "max_score": 1.3862944, + "hits": [ + { + "_index": "test", + "_type": "test", + "_id": "AVi3aRDmGKWpaS38wV57", + "_score": 1.3862944, + "_source": { + "name": "fred", + "age": 69, + "hair": "blonde" + } + } + ] + }, + "profile": { + "shards": [ + { + "id": "[O-l25nM4QN6Z68UA5rUYqQ][test][0]", + "searches": [ + { + "query": [ + { + "type": "BooleanQuery", + "description": "+name:fred #(ConstantScore(*:*))^0.0", + "time": "0.5884370000ms", + "breakdown": { + "score": 7243, + "build_scorer_count": 1, + "match_count": 0, + "create_weight": 196239, + "next_doc": 9851, + "match": 0, + "create_weight_count": 1, + "next_doc_count": 2, + "score_count": 1, + "build_scorer": 375099, + "advance": 0, + "advance_count": 0 + }, + "children": [ + { + "type": "TermQuery", + "description": "name:fred", + "time": "0.3016880000ms", + "breakdown": { + "score": 4218, + "build_scorer_count": 1, + "match_count": 0, + "create_weight": 132425, + "next_doc": 2196, + "match": 0, + "create_weight_count": 1, + "next_doc_count": 2, + "score_count": 1, + "build_scorer": 162844, + "advance": 0, + "advance_count": 0 + } + }, + { + "type": "BoostQuery", + "description": "(ConstantScore(*:*))^0.0", + "time": "0.1223030000ms", + "breakdown": { + "score": 0, + "build_scorer_count": 1, + "match_count": 0, + "create_weight": 17366, + "next_doc": 0, + "match": 0, + "create_weight_count": 1, + "next_doc_count": 0, + "score_count": 0, + "build_scorer": 102329, + "advance": 2604, + "advance_count": 2 + }, + "children": [ + { + "type": "MatchAllDocsQuery", + "description": "*:*", + "time": "0.03307600000ms", + "breakdown": { + "score": 0, + "build_scorer_count": 1, + "match_count": 0, + "create_weight": 6068, + "next_doc": 0, + "match": 0, + "create_weight_count": 1, + "next_doc_count": 0, + "score_count": 0, + "build_scorer": 25615, + "advance": 1389, + "advance_count": 2 + } + } + ] + } + ] + } + ], + "rewrite_time": 168640, + "collector": [ + { + "name": "CancellableCollector", + "reason": "search_cancelled", + "time": "0.02952900000ms", + "children": [ + { + "name": "SimpleTopScoreDocCollector", + "reason": "search_top_hits", + "time": "0.01931700000ms" + } + ] + } + ] + } + ], + "aggregations": [] + } + ] + } +} +-------------------------------------------------- +// NOTCONSOLE + +Your output should look similar to this: + +[role="screenshot"] +image::dev-tools/searchprofiler/images/search-profiler-json.png["Rendering pre-captured profiler JSON"] diff --git a/docs/dev-tools/searchprofiler/more-complicated.asciidoc b/docs/dev-tools/searchprofiler/more-complicated.asciidoc deleted file mode 100644 index 338341d65924dd..00000000000000 --- a/docs/dev-tools/searchprofiler/more-complicated.asciidoc +++ /dev/null @@ -1,104 +0,0 @@ -[role="xpack"] -[[profiler-complicated]] -=== Profiling a more complicated query - -To understand how the query trees are displayed inside the {searchprofiler}, -let's look at a more complicated query. - -. Index the following data via *Console*: -+ --- -[source,js] --------------------------------------------------- -POST test/_bulk -{"index":{}} -{"name":"aaron","age":23,"hair":"brown"} -{"index":{}} -{"name":"sue","age":19,"hair":"red"} -{"index":{}} -{"name":"sally","age":19,"hair":"blonde"} -{"index":{}} -{"name":"george","age":19,"hair":"blonde"} -{"index":{}} -{"name":"fred","age":69,"hair":"blonde"} --------------------------------------------------- -// CONSOLE --- - -. From the {searchprofiler}, enter "test" in the *Index* field to restrict profiled -queries to the `test` index. - -. Replace the default `match_all` query in the query editor with a query that has two sub-query -components and includes a simple aggregation: -+ --- -[source,js] --------------------------------------------------- -{ - "query": { - "bool": { - "should": [ - { - "match": { - "name": "fred" - } - }, - { - "terms": { - "name": [ - "sue", - "sally" - ] - } - } - ] - } - }, - "aggs": { - "stats": { - "stats": { - "field": "price" - } - } - } -} --------------------------------------------------- -// NOTCONSOLE --- - -. Click *Profile* to profile the query and visualize the results. -. Select the shard to view the query details. -+ -[role="screenshot"] -image::dev-tools/searchprofiler/images/gs8.png["Profiling the more complicated query"] - - -The detail view contains a row for each query component: - - - The top-level `BooleanQuery` component corresponds to the bool in the query. - - The second `BooleanQuery` corresponds to the terms query, which is internally - converted to a `Boolean` of should clauses. It has two child queries that correspond - to "sue" and "sally" from the terms query. - - The `TermQuery` that's labeled with "name:fred" corresponds to match: fred in the query. - -If you look at the time columns, you can see that "Self time" and "Total time" are no longer -identical on all the rows. Self time represents how long the query component took to execute. -Total time is the time a query component and all its children took to execute. -Therefore, queries like the Boolean queries often have a larger total time than self time. - - -==== Aggregations - -This particular query also includes a aggregation (a `stats` agg on the `"age"` field). -Click *Aggregation Profile* to view aggregation profiling statistics (this tab -is only enabled if the query being profiled contains an aggregation). - - -Select the name of the shard to view the aggregation details and timing breakdown. - -[role="screenshot"] -image::dev-tools/searchprofiler/images/gs10.png["Drilling into the first shard's details"] - -For more information about how the {searchprofiler} works, how timings are calculated, and -how to interpret various results, see -{ref}/search-profile.html#profiling-queries[Profiling queries]. diff --git a/docs/dev-tools/searchprofiler/pasting.asciidoc b/docs/dev-tools/searchprofiler/pasting.asciidoc deleted file mode 100644 index 9257a4d84fb562..00000000000000 --- a/docs/dev-tools/searchprofiler/pasting.asciidoc +++ /dev/null @@ -1,161 +0,0 @@ -[role="xpack"] -[[profiler-render]] -=== Rendering pre-captured profiler JSON - -The {searchprofiler} queries the cluster that the Kibana node is attached to. -It does this by executing the query against the cluster and collecting the results. - -But sometimes you may want to investigate performance problems that are temporal in nature. -For example, a query might only be slow at certain time of day when many customers are using your system. -You can setup a process to automatically profile slow queries when they occur and then -save those profile responses for later analysis. - -The {searchprofiler} supports this workflow by allowing you to paste the -pre-captured JSON in the query editor. The {searchprofiler} will detect that you -have entered a JSON response (rather than a query) and will just render the visualization, -rather than querying the cluster. - -To see how this works, copy and paste the following profile response into the -query editor and click *Profile*. - -[source,js] --------------------------------------------------- -{ - "took": 3, - "timed_out": false, - "_shards": { - "total": 1, - "successful": 1, - "failed": 0 - }, - "hits": { - "total": 1, - "max_score": 1.3862944, - "hits": [ - { - "_index": "test", - "_type": "test", - "_id": "AVi3aRDmGKWpaS38wV57", - "_score": 1.3862944, - "_source": { - "name": "fred", - "age": 69, - "hair": "blonde" - } - } - ] - }, - "profile": { - "shards": [ - { - "id": "[O-l25nM4QN6Z68UA5rUYqQ][test][0]", - "searches": [ - { - "query": [ - { - "type": "BooleanQuery", - "description": "+name:fred #(ConstantScore(*:*))^0.0", - "time": "0.5884370000ms", - "breakdown": { - "score": 7243, - "build_scorer_count": 1, - "match_count": 0, - "create_weight": 196239, - "next_doc": 9851, - "match": 0, - "create_weight_count": 1, - "next_doc_count": 2, - "score_count": 1, - "build_scorer": 375099, - "advance": 0, - "advance_count": 0 - }, - "children": [ - { - "type": "TermQuery", - "description": "name:fred", - "time": "0.3016880000ms", - "breakdown": { - "score": 4218, - "build_scorer_count": 1, - "match_count": 0, - "create_weight": 132425, - "next_doc": 2196, - "match": 0, - "create_weight_count": 1, - "next_doc_count": 2, - "score_count": 1, - "build_scorer": 162844, - "advance": 0, - "advance_count": 0 - } - }, - { - "type": "BoostQuery", - "description": "(ConstantScore(*:*))^0.0", - "time": "0.1223030000ms", - "breakdown": { - "score": 0, - "build_scorer_count": 1, - "match_count": 0, - "create_weight": 17366, - "next_doc": 0, - "match": 0, - "create_weight_count": 1, - "next_doc_count": 0, - "score_count": 0, - "build_scorer": 102329, - "advance": 2604, - "advance_count": 2 - }, - "children": [ - { - "type": "MatchAllDocsQuery", - "description": "*:*", - "time": "0.03307600000ms", - "breakdown": { - "score": 0, - "build_scorer_count": 1, - "match_count": 0, - "create_weight": 6068, - "next_doc": 0, - "match": 0, - "create_weight_count": 1, - "next_doc_count": 0, - "score_count": 0, - "build_scorer": 25615, - "advance": 1389, - "advance_count": 2 - } - } - ] - } - ] - } - ], - "rewrite_time": 168640, - "collector": [ - { - "name": "CancellableCollector", - "reason": "search_cancelled", - "time": "0.02952900000ms", - "children": [ - { - "name": "SimpleTopScoreDocCollector", - "reason": "search_top_hits", - "time": "0.01931700000ms" - } - ] - } - ] - } - ], - "aggregations": [] - } - ] - } -} --------------------------------------------------- -// NOTCONSOLE - -image::dev-tools/searchprofiler/images/pasting.png["Visualizing pre-collected responses"] diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index c1535e8a2146f0..8e08e5f4db1f98 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -99,6 +99,10 @@ want to incorporate their own functions, types, and renderers into the service for use in their own application. +|{kib-repo}blob/{branch}/src/plugins/expression_shape/README.md[expressionShape] +|Expression Shape plugin adds a shape function to the expression plugin and an associated renderer. The renderer will display the given shape with selected decorations. + + |{kib-repo}blob/{branch}/src/plugins/home/README.md[home] |Moves the legacy ui/registry/feature_catalogue module for registering "features" that should be shown in the home page's feature catalogue to a service within a "home" plugin. The feature catalogue refered to here should not be confused with the "feature" plugin for registering features used to derive UI capabilities for feature controls. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 66a23ee189ae1f..32736522d583a9 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -480,10 +480,11 @@ of buckets to try to represent. [horizontal] [[visualization-visualize-chartslibrary]]`visualization:visualize:legacyChartsLibrary`:: -Enables the legacy charts library for aggregation-based area, line, and bar charts in *Visualize*. +**The legacy XY charts are deprecated and will not be supported as of 7.16.** +The visualize editor uses a new XY charts library with improved performance, color palettes, fill capacity, and more. Enable this option if you prefer to use the legacy charts library. [[visualization-visualize-pieChartslibrary]]`visualization:visualize:legacyPieChartsLibrary`:: -Enables the legacy charts library for aggregation-based pie charts in *Visualize*. +The visualize editor uses new pie charts with improved performance, color palettes, label positioning, and more. Enable this option if you prefer to use to the legacy charts library. [[visualization-colormapping]]`visualization:colorMapping`:: **This setting is deprecated and will not be supported as of 8.0.** diff --git a/docs/management/images/tags/bulk-assign-selection.png b/docs/management/images/tags/bulk-assign-selection.png deleted file mode 100644 index 1c8687226b51fe..00000000000000 Binary files a/docs/management/images/tags/bulk-assign-selection.png and /dev/null differ diff --git a/docs/management/images/tags/create-tag.png b/docs/management/images/tags/create-tag.png deleted file mode 100644 index a88e754457b9f6..00000000000000 Binary files a/docs/management/images/tags/create-tag.png and /dev/null differ diff --git a/docs/management/images/tags/manage-assignments-flyout.png b/docs/management/images/tags/manage-assignments-flyout.png index a4e0b7a49d96a7..92a78be5f04028 100644 Binary files a/docs/management/images/tags/manage-assignments-flyout.png and b/docs/management/images/tags/manage-assignments-flyout.png differ diff --git a/docs/management/images/tags/tag-management-section.png b/docs/management/images/tags/tag-management-section.png index 4aae3ea067820c..34addfe4d326fd 100644 Binary files a/docs/management/images/tags/tag-management-section.png and b/docs/management/images/tags/tag-management-section.png differ diff --git a/docs/management/managing-tags.asciidoc b/docs/management/managing-tags.asciidoc index 88fdef66a74183..a0b3dce7f4b278 100644 --- a/docs/management/managing-tags.asciidoc +++ b/docs/management/managing-tags.asciidoc @@ -2,28 +2,26 @@ [[managing-tags]] == Tags -Tags enable you to categorize your saved objects. You can then easily filter for related objects based on shared tags. - -To begin, open the main menu, click *Stack Management*, then click *Tags*. +Tags enable you to categorize your saved objects. +You can then filter for related objects based on shared tags. [role="screenshot"] -image::images/tags/tag-management-section.png[Tags management section] +image::images/tags/tag-management-section.png[Tags management] [float] === Required permissions -Access to *Tags* requires the `Tag Management` {kib} privilege. To add the privilege, open the menu, -click *Stack Management*, then click *Roles*. - -In addition: +To create tags, you must meet the minimum requirements. +* Access to *Tags* requires the `Tag Management` Kibana privilege. To add the privilege, open the main menu, +and then click *Stack Management > Roles*. * The `read` privilege allows you to assign tags to the saved objects for which you have write permission. * The `write` privilege enables you to create, edit, and delete tags. - NOTE: Having the `Tag Management` {kib} privilege is not required to -view tags assigned on objects the user has `read` access to, or to filter objects by tags -in {kib} applications or from the navigational search. +view tags assigned on objects you have `read` access to, or to filter objects by tags +from the global search. + [float] [[settings-create-tag]] @@ -31,10 +29,9 @@ in {kib} applications or from the navigational search. Create a tag to assign to your saved objects. +. Open the main menu, and then click *Stack Management > Tags*. . Click *Create tag*. -+ -[role="screenshot"] -image::images/tags/create-tag.png[Tag creation popin] + . Enter a name and select a color for the new tag. + The name cannot be longer than 50 characters. @@ -42,33 +39,32 @@ The name cannot be longer than 50 characters. [float] [[settings-assign-tag]] -=== Assign a tag to saved objects +=== Assign a tag to an object -Assign or remove tags to one or more saved objects. You must have `write` permission +To assign and remove tags from saved objects, you must have `write` permission on the objects to which you assign the tags. -. Click the action (...) icon in the tag row, and then select the *Manage assignments* action. +. In the *Tags* view, find the tag you want to assign. +. Click the action menu (...) in the tag row, +and then select the *Manage assignments* action. + +. Select the objects to which you want to assign or remove tags. + [role="screenshot"] image::images/tags/manage-assignments-flyout.png[Assign flyout] -. Select the objects to which you want to assign or remove tags. -. Click on *Save tag assignments*. -TIP: To assign multiple tags to objects at once, select their checkboxes -and then select *Manage tag assignments* from the *selected tags* menu. +. Click *Save tag assignments*. -[role="screenshot"] -image::images/tags/bulk-assign-selection.png[Bulk assign tags] +TIP: To assign, delete, or clear multiple tags at once, +select their checkboxes in the *Tags* view, and then select +the desired action from the *selected tags* menu. [float] [[settings-delete-tag]] === Delete a tag -Delete a tag and remove it from any saved objects. +When you delete a tag, you remove it from all saved objects that use it. -. Click the action (...) icon in the tag row, and then select the *Delete* action. +. Click the action menu (...) in the tag row, and then select the *Delete* action. . Click *Delete tag*. - -TIP: To delete multiple tags at once, select their checkboxes in the list view, -and then select *Delete* action from the *selected tags* menu. \ No newline at end of file diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index eb3130ba6fdb5f..b49b669a007806 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -316,4 +316,21 @@ This content has moved. Refer to <> and <>. \ No newline at end of file +This content has moved. Refer to <>. + +[role="exclude",id="profiler-getting-started"] +== Getting start with Search Profiler + +This content has moved. Refer to <>. + + +[role="exclude",id="profiler-complicated"] +== Profiling a more complicated querying + +This content has moved. Refer to <>. + + +[role="exclude",id="profiler-render"] +== Rendering pre-captured profiler JSON + +This content has moved. Refer to <>. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index d1d283ca60fbbb..a523c2cb005a21 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -207,4 +207,10 @@ Use `full` to perform hostname verification, `certificate` to skip hostname veri [[alert-settings]] ==== Alerting settings -You do not need to configure any additional settings to use alerting in {kib}. +[cols="2*<"] +|=== + +| `xpack.alerting.maxEphemeralActionsPerAlert` + | Sets the number of actions that will be executed ephemerally. To use this, enable ephemeral tasks in task manager first with <> + +|=== \ No newline at end of file diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index dfb239f0e26c06..67de6f8d24960c 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -65,7 +65,7 @@ Changing these settings may disable features of the APM App. | Index name where Observability annotations are stored. Defaults to `observability-annotations`. | `xpack.apm.searchAggregatedTransactions` - | experimental[] Enables Transaction histogram metrics. Defaults to `false`. When `true`, additional configuration in APM Server is required. + | experimental[] Enables Transaction histogram metrics. Defaults to `auto` and the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. When set to `never`, aggregated transactions are not used. See {apm-server-ref-v}/transaction-metrics.html[Configure transaction metrics] for more information. | `apm_oss.indexPattern` {ess-icon} diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index a0dd8750ffc8f6..455ee76deefe30 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -271,7 +271,7 @@ You can configure the following settings in the `kibana.yml` file. |[[xpack-session-idleTimeout]] `xpack.security.session.idleTimeout` {ess-icon} | Ensures that user sessions will expire after a period of inactivity. This and <> are both -highly recommended. You can also specify this setting for <>. If this is _not_ set or set to `0`, then sessions will never expire due to inactivity. By default, this setting is not set. +highly recommended. You can also specify this setting for <>. If this is set to `0`, then sessions will never expire due to inactivity. By default, this value is 1 hour. 2+a| [TIP] @@ -281,8 +281,8 @@ Use a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w |[[xpack-session-lifespan]] `xpack.security.session.lifespan` {ess-icon} | Ensures that user sessions will expire after the defined time period. This behavior is also known as an "absolute timeout". If -this is _not_ set or set to `0`, user sessions could stay active indefinitely. This and <> are both highly -recommended. You can also specify this setting for <>. By default, this setting is not set. +this is set to `0`, user sessions could stay active indefinitely. This and <> are both highly +recommended. You can also specify this setting for <>. By default, this value is 30 days. 2+a| [TIP] diff --git a/docs/settings/task-manager-settings.asciidoc b/docs/settings/task-manager-settings.asciidoc index 7f4dbb3a96e6b0..fa89b7780e475f 100644 --- a/docs/settings/task-manager-settings.asciidoc +++ b/docs/settings/task-manager-settings.asciidoc @@ -37,6 +37,14 @@ Task Manager runs background tasks by polling for work on an interval. You can `monitored_stats_health_verbose_log.` `warn_delayed_task_start_in_seconds` | The amount of seconds we allow a task to delay before printing a warning server log. Defaults to 60. + + | `xpack.task_manager.ephemeral_tasks.enabled` + | Enables an experimental feature that executes a limited (and configurable) number of actions in the same task as the alert which triggered them. + These action tasks will reduce the latency of the time it takes an action to run after it's triggered, but are not persisted as SavedObjects. + These non-persisted action tasks have a risk that they won't be run at all if the Kibana instance running them exits unexpectedly. Defaults to false. + + | `xpack.task_manager.ephemeral_tasks.request_capacity` + | Sets the size of the ephemeral queue defined above. Defaults to 10. |=== [float] diff --git a/docs/user/dashboard/aggregation-based.asciidoc b/docs/user/dashboard/aggregation-based.asciidoc index cb102b73f93b43..7559cd3c1b1b82 100644 --- a/docs/user/dashboard/aggregation-based.asciidoc +++ b/docs/user/dashboard/aggregation-based.asciidoc @@ -178,4 +178,3 @@ image:images/bar-chart-tutorial-2.png[Bar chart with sample logs data] - diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc index ac7a777eb05807..b0f27d45bb826e 100644 --- a/docs/user/security/session-management.asciidoc +++ b/docs/user/security/session-management.asciidoc @@ -12,24 +12,24 @@ To manage user sessions programmatically, {kib} exposes <[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 1 hour of inactivity: +By default, sessions expire after 1 hour of inactivity. To define another value for a sliding session expiration, set the property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 30 minutes of inactivity: -- [source,yaml] -------------------------------------------------------------------------------- -xpack.security.session.idleTimeout: "1h" +xpack.security.session.idleTimeout: "30m" -------------------------------------------------------------------------------- -- [[session-lifespan]] ==== Session lifespan -You can use `xpack.security.session.lifespan` to configure the maximum session duration or "lifespan" -- also known as the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly recommended. By default, sessions don't have a fixed lifespan, and if an idle timeout is defined, a session can still be extended indefinitely. To define a maximum session lifespan, set the property in the `kibana.yml` configuration file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the lifespan to expire sessions after 30 days: +You can use `xpack.security.session.lifespan` to configure the maximum session duration or "lifespan" -- also known as the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly recommended. By default, a maximum session lifespan is 30 days. To define another lifespan, set the property in the `kibana.yml` configuration file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the lifespan to expire sessions after 7 days: -- [source,yaml] -------------------------------------------------------------------------------- -xpack.security.session.lifespan: "30d" +xpack.security.session.lifespan: "7d" -------------------------------------------------------------------------------- -- @@ -38,7 +38,7 @@ xpack.security.session.lifespan: "30d" [IMPORTANT] ============================================================================ -If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out. This might lead to an infinitely growing session index. Configure the idle timeout and lifespan settings for the {kib} sessions so that they can be cleaned up even if you don't explicitly log out. +If you disable session idle timeout and lifespan, then Kibana will not automatically remove session information from the index unless you explicitly log out. This might lead to an infinitely growing session index. As long as either idle timeout or lifespan is configured, Kibana sessions will be cleaned up even if you don't explicitly log out. ============================================================================ You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, schedule the session index cleanup to perform once a day: diff --git a/packages/kbn-dev-utils/BUILD.bazel b/packages/kbn-dev-utils/BUILD.bazel index 9109f766e0e9f6..7c9beafc711ee5 100644 --- a/packages/kbn-dev-utils/BUILD.bazel +++ b/packages/kbn-dev-utils/BUILD.bazel @@ -43,6 +43,7 @@ NPM_MODULE_EXTRA_FILES = [ SRC_DEPS = [ "//packages/kbn-expect", + "//packages/kbn-std", "//packages/kbn-utils", "@npm//@babel/core", "@npm//axios", @@ -60,10 +61,12 @@ SRC_DEPS = [ "@npm//moment", "@npm//normalize-path", "@npm//rxjs", + "@npm//tar", "@npm//tree-kill", "@npm//tslib", "@npm//typescript", - "@npm//vinyl" + "@npm//vinyl", + "@npm//yauzl" ] TYPES_DEPS = [ @@ -76,8 +79,10 @@ TYPES_DEPS = [ "@npm//@types/node", "@npm//@types/normalize-path", "@npm//@types/react", + "@npm//@types/tar", "@npm//@types/testing-library__jest-dom", - "@npm//@types/vinyl" + "@npm//@types/vinyl", + "@npm//@types/yauzl" ] DEPS = SRC_DEPS + TYPES_DEPS diff --git a/packages/kbn-dev-utils/src/extract.ts b/packages/kbn-dev-utils/src/extract.ts new file mode 100644 index 00000000000000..05ad2b4bd99ec2 --- /dev/null +++ b/packages/kbn-dev-utils/src/extract.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +import { lastValueFrom } from '@kbn/std'; +import Tar from 'tar'; +import Yauzl, { ZipFile, Entry } from 'yauzl'; +import * as Rx from 'rxjs'; +import { map, mergeMap, takeUntil } from 'rxjs/operators'; + +const asyncPipeline = promisify(pipeline); + +interface Options { + /** + * Path to the archive to extract, .tar, .tar.gz, and .zip archives are supported + */ + archivePath: string; + + /** + * Directory where the contents of the archive will be written. Existing files in that + * directory will be overwritten. If the directory doesn't exist it will be created. + */ + targetDir: string; + + /** + * Number of path segments to strip form paths in the archive, like --strip-components from tar + */ + stripComponents?: number; +} + +/** + * Extract tar and zip archives using a single function, supporting stripComponents + * for both archive types, only tested with familiar archives we create so might not + * support some weird exotic zip features we don't use in our own snapshot/build tooling + */ +export async function extract({ archivePath, targetDir, stripComponents = 0 }: Options) { + await Fs.mkdir(targetDir, { recursive: true }); + + if (archivePath.endsWith('.tar') || archivePath.endsWith('.tar.gz')) { + return await Tar.x({ + file: archivePath, + cwd: targetDir, + stripComponents, + }); + } + + if (!archivePath.endsWith('.zip')) { + throw new Error('unsupported archive type'); + } + + // zip mode + const zipFile = await new Promise((resolve, reject) => { + Yauzl.open(archivePath, { lazyEntries: true }, (error, _zipFile) => { + if (error || !_zipFile) { + reject(error || new Error('no zipfile provided by yauzl')); + } else { + resolve(_zipFile); + } + }); + }); + + // bound version of zipFile.openReadStream which returns an observable, because of type defs the readStream + // result is technically optional (thanks callbacks) + const openReadStream$ = Rx.bindNodeCallback(zipFile.openReadStream.bind(zipFile)); + + const close$ = Rx.fromEvent(zipFile, 'close'); + const error$ = Rx.fromEvent(zipFile, 'error').pipe( + takeUntil(close$), + map((error) => { + throw error; + }) + ); + + const entry$ = Rx.fromEvent(zipFile, 'entry').pipe( + takeUntil(close$), + mergeMap((entry) => { + const entryPath = entry.fileName.split(/\/|\\/).slice(stripComponents).join(Path.sep); + const fileName = Path.resolve(targetDir, entryPath); + + // detect directories + if (entry.fileName.endsWith('/')) { + return Rx.defer(async () => { + // ensure the directory exists + await Fs.mkdir(fileName, { recursive: true }); + // tell yauzl to read the next entry + zipFile.readEntry(); + }); + } + + // file entry + return openReadStream$(entry).pipe( + mergeMap(async (readStream) => { + if (!readStream) { + throw new Error('no readstream provided by yauzl'); + } + + // write the file contents to disk + await asyncPipeline(readStream, createWriteStream(fileName)); + // tell yauzl to read the next entry + zipFile.readEntry(); + }) + ); + }) + ); + + // trigger the initial 'entry' event, happens async so the event will be delivered after the observable is subscribed + zipFile.readEntry(); + + await lastValueFrom(Rx.merge(entry$, error$)); +} diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 3ac3927d25c056..9dc9d1723945a4 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -31,3 +31,4 @@ export * from './plugin_list'; export * from './plugins'; export * from './streams'; export * from './babel'; +export * from './extract'; diff --git a/packages/kbn-dev-utils/src/run/help.ts b/packages/kbn-dev-utils/src/run/help.ts index bd85922d00207d..ea197d18130861 100644 --- a/packages/kbn-dev-utils/src/run/help.ts +++ b/packages/kbn-dev-utils/src/run/help.ts @@ -8,6 +8,7 @@ import Path from 'path'; +import chalk from 'chalk'; import 'core-js/features/string/repeat'; import dedent from 'dedent'; @@ -116,7 +117,7 @@ export function getHelpForAllCommands({ : ''; return [ - dedent(command.usage || '') || command.name, + chalk.bold.whiteBright.bgBlack(` ${dedent(command.usage || '') || command.name} `), ` ${indent(dedent(command.description || 'Runs a dev task'), 2)}`, ...([indent(options, 2)] || []), ].join('\n'); diff --git a/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts b/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts index 122492d03a6f21..3bb69bcb580a36 100644 --- a/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts +++ b/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts @@ -6,9 +6,12 @@ * Side Public License, v 1. */ -export function createAnyInstanceSerializer(Class: Function, name?: string) { +export function createAnyInstanceSerializer( + Class: Function, + name?: string | ((instance: any) => string) +) { return { test: (v: any) => v instanceof Class, - serialize: () => `<${name ?? Class.name}>`, + serialize: (v: any) => `<${typeof name === 'function' ? name(v) : name ?? Class.name}>`, }; } diff --git a/packages/kbn-es/BUILD.bazel b/packages/kbn-es/BUILD.bazel index 48f0fb58e983fc..8d50d4e34e296e 100644 --- a/packages/kbn-es/BUILD.bazel +++ b/packages/kbn-es/BUILD.bazel @@ -39,9 +39,7 @@ DEPS = [ "@npm//glob", "@npm//node-fetch", "@npm//simple-git", - "@npm//tar-fs", "@npm//tree-kill", - "@npm//yauzl", "@npm//zlib" ] diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 52cfd0b71b8bae..32709fc608617e 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -13,18 +13,12 @@ const chalk = require('chalk'); const path = require('path'); const { downloadSnapshot, installSnapshot, installSource, installArchive } = require('./install'); const { ES_BIN } = require('./paths'); -const { - log: defaultLog, - parseEsLog, - extractConfigFiles, - decompress, - NativeRealm, -} = require('./utils'); +const { log: defaultLog, parseEsLog, extractConfigFiles, NativeRealm } = require('./utils'); const { createCliError } = require('./errors'); const { promisify } = require('util'); const treeKillAsync = promisify(require('tree-kill')); const { parseSettings, SettingsFilter } = require('./settings'); -const { CA_CERT_PATH, ES_P12_PATH, ES_P12_PASSWORD } = require('@kbn/dev-utils'); +const { CA_CERT_PATH, ES_P12_PATH, ES_P12_PASSWORD, extract } = require('@kbn/dev-utils'); const readFile = util.promisify(fs.readFile); // listen to data on stream until map returns anything but undefined @@ -144,13 +138,17 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold(`Extracting data directory`)); this._log.indent(4); - // decompress excludes the root directory as that is how our archives are + // stripComponents=1 excludes the root directory as that is how our archives are // structured. This works in our favor as we can explicitly extract into the data dir const extractPath = path.resolve(installPath, extractDirName); this._log.info(`Data archive: ${archivePath}`); this._log.info(`Extract path: ${extractPath}`); - await decompress(archivePath, extractPath); + await extract({ + archivePath, + targetDir: extractPath, + stripComponents: 1, + }); this._log.indent(-4); } diff --git a/packages/kbn-es/src/install/archive.js b/packages/kbn-es/src/install/archive.js index 84c694f6fa9f0b..76db5a4427e6d0 100644 --- a/packages/kbn-es/src/install/archive.js +++ b/packages/kbn-es/src/install/archive.js @@ -12,7 +12,8 @@ const chalk = require('chalk'); const execa = require('execa'); const del = require('del'); const url = require('url'); -const { log: defaultLog, decompress } = require('../utils'); +const { extract } = require('@kbn/dev-utils'); +const { log: defaultLog } = require('../utils'); const { BASE_PATH, ES_CONFIG, ES_KEYSTORE_BIN } = require('../paths'); const { Artifact } = require('../artifact'); const { parseSettings, SettingsFilter } = require('../settings'); @@ -50,7 +51,11 @@ exports.installArchive = async function installArchive(archive, options = {}) { } log.info('extracting %s', chalk.bold(dest)); - await decompress(dest, installPath); + await extract({ + archivePath: dest, + targetDir: installPath, + stripComponents: 1, + }); log.info('extracted to %s', chalk.bold(installPath)); const tmpdir = path.resolve(installPath, 'ES_TMPDIR'); diff --git a/packages/kbn-es/src/utils/decompress.js b/packages/kbn-es/src/utils/decompress.js deleted file mode 100644 index c895f2f7986077..00000000000000 --- a/packages/kbn-es/src/utils/decompress.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const fs = require('fs'); -const path = require('path'); - -const yauzl = require('yauzl'); -const zlib = require('zlib'); -const tarFs = require('tar-fs'); - -function decompressTarball(archive, dirPath) { - return new Promise((resolve, reject) => { - fs.createReadStream(archive) - .on('error', reject) - .pipe(zlib.createGunzip()) - .on('error', reject) - .pipe(tarFs.extract(dirPath, { strip: true })) - .on('error', reject) - .on('finish', resolve); - }); -} - -function decompressZip(input, output) { - fs.mkdirSync(output, { recursive: true }); - return new Promise((resolve, reject) => { - yauzl.open(input, { lazyEntries: true }, (err, zipfile) => { - if (err) { - reject(err); - } - - zipfile.readEntry(); - - zipfile.on('close', () => { - resolve(); - }); - - zipfile.on('error', (err) => { - reject(err); - }); - - zipfile.on('entry', (entry) => { - const zipPath = entry.fileName.split(/\/|\\/).slice(1).join(path.sep); - const fileName = path.resolve(output, zipPath); - - if (/\/$/.test(entry.fileName)) { - fs.mkdirSync(fileName, { recursive: true }); - zipfile.readEntry(); - } else { - // file entry - zipfile.openReadStream(entry, (err, readStream) => { - if (err) { - reject(err); - } - - readStream.on('end', () => { - zipfile.readEntry(); - }); - - readStream.pipe(fs.createWriteStream(fileName)); - }); - } - }); - }); - }); -} - -exports.decompress = async function (input, output) { - const ext = path.extname(input); - - switch (path.extname(input)) { - case '.zip': - await decompressZip(input, output); - break; - case '.tar': - case '.gz': - await decompressTarball(input, output); - break; - default: - throw new Error(`unknown extension "${ext}"`); - } -}; diff --git a/packages/kbn-es/src/utils/decompress.test.js b/packages/kbn-es/src/utils/decompress.test.js deleted file mode 100644 index 0f9ac797e92076..00000000000000 --- a/packages/kbn-es/src/utils/decompress.test.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -const { decompress } = require('./decompress'); -const fs = require('fs'); -const path = require('path'); -const del = require('del'); -const os = require('os'); - -const fixturesFolder = path.resolve(__dirname, '__fixtures__'); -const randomDir = Math.random().toString(36); -const tmpFolder = path.resolve(os.tmpdir(), randomDir); -const dataFolder = path.resolve(tmpFolder, 'data'); -const esFolder = path.resolve(tmpFolder, '.es'); - -const zipSnapshot = path.resolve(dataFolder, 'snapshot.zip'); -const tarGzSnapshot = path.resolve(dataFolder, 'snapshot.tar.gz'); - -beforeEach(() => { - fs.mkdirSync(tmpFolder, { recursive: true }); - fs.mkdirSync(dataFolder, { recursive: true }); - fs.mkdirSync(esFolder, { recursive: true }); - - fs.copyFileSync(path.resolve(fixturesFolder, 'snapshot.zip'), zipSnapshot); - fs.copyFileSync(path.resolve(fixturesFolder, 'snapshot.tar.gz'), tarGzSnapshot); -}); - -afterEach(() => { - del.sync(tmpFolder, { force: true }); -}); - -test('zip strips root directory', async () => { - await decompress(zipSnapshot, path.resolve(esFolder, 'foo')); - expect(fs.readdirSync(path.resolve(esFolder, 'foo/bin'))).toContain('elasticsearch.bat'); -}); - -test('tar strips root directory', async () => { - await decompress(tarGzSnapshot, path.resolve(esFolder, 'foo')); - expect(fs.readdirSync(path.resolve(esFolder, 'foo/bin'))).toContain('elasticsearch'); -}); diff --git a/packages/kbn-es/src/utils/index.js b/packages/kbn-es/src/utils/index.js index 2715d7b675657e..ed83495e5310aa 100644 --- a/packages/kbn-es/src/utils/index.js +++ b/packages/kbn-es/src/utils/index.js @@ -11,7 +11,6 @@ exports.log = require('./log').log; exports.parseEsLog = require('./parse_es_log').parseEsLog; exports.findMostRecentlyChanged = require('./find_most_recently_changed').findMostRecentlyChanged; exports.extractConfigFiles = require('./extract_config_files').extractConfigFiles; -exports.decompress = require('./decompress').decompress; exports.NativeRealm = require('./native_realm').NativeRealm; exports.buildSnapshot = require('./build_snapshot').buildSnapshot; exports.archiveForPlatform = require('./build_snapshot').archiveForPlatform; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 2f6765cd57b9e1..7a2c9095e20111 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -107,10 +107,11 @@ pageLoadAssetSize: dataVisualizer: 27530 banners: 17946 mapsEms: 26072 - timelines: 251886 + timelines: 330000 screenshotMode: 17856 visTypePie: 35583 expressionRevealImage: 25675 cases: 144442 expressionError: 22127 userSetup: 18532 + expressionShape: 30033 diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index 48d36b706b8312..97a7f33be673d0 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -15,7 +15,7 @@ import cpy from 'cpy'; import del from 'del'; import { tap, filter } from 'rxjs/operators'; import { REPO_ROOT } from '@kbn/utils'; -import { ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog, createReplaceSerializer } from '@kbn/dev-utils'; import { runOptimizer, OptimizerConfig, OptimizerUpdate, logOptimizerState } from '../index'; import { allValuesFrom } from '../common'; @@ -29,6 +29,8 @@ expect.addSnapshotSerializer({ test: (value: any) => typeof value === 'string' && value.includes(REPO_ROOT), }); +expect.addSnapshotSerializer(createReplaceSerializer(/\w+-fastbuild/, '-fastbuild')); + const log = new ToolingLog({ level: 'error', writeTo: { @@ -130,7 +132,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(foo.cache.getModuleCount()).toBe(6); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/bazel-out/-fastbuild/bin/packages/kbn-ui-shared-deps/target/public_path_module_creator.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, @@ -153,7 +155,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { /node_modules/@kbn/optimizer/postcss.config.js, /node_modules/css-loader/package.json, /node_modules/style-loader/package.json, - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/bazel-out/-fastbuild/bin/packages/kbn-ui-shared-deps/target/public_path_module_creator.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.scss, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, @@ -173,7 +175,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(baz.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/bazel-out/-fastbuild/bin/packages/kbn-ui-shared-deps/target/public_path_module_creator.js, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/kibana.json, /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/x-pack/baz/public/index.ts, /packages/kbn-optimizer/src/worker/entry_point_creator.ts, diff --git a/packages/kbn-optimizer/src/optimizer/watcher.ts b/packages/kbn-optimizer/src/optimizer/watcher.ts index d0420d3c3699d9..65958d6669f73b 100644 --- a/packages/kbn-optimizer/src/optimizer/watcher.ts +++ b/packages/kbn-optimizer/src/optimizer/watcher.ts @@ -38,7 +38,6 @@ export class Watcher { private readonly watchpack = new Watchpack({ aggregateTimeout: 0, - ignored: /node_modules\/([^\/]+[\/])*(?!package.json)([^\/]+)$/, }); private readonly change$ = Rx.fromEvent<[string]>(this.watchpack, 'change').pipe(share()); diff --git a/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts index a3455d7ddf2b96..bc8418811e7aee 100644 --- a/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts +++ b/packages/kbn-optimizer/src/worker/populate_bundle_cache_plugin.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import Fs from 'fs'; import Path from 'path'; import { inspect } from 'util'; @@ -21,20 +22,6 @@ import { getModulePath, } from './webpack_helpers'; -function tryToResolveRewrittenPath(from: string, toResolve: string) { - try { - return require.resolve(toResolve); - } catch (error) { - if (error.code === 'MODULE_NOT_FOUND') { - throw new Error( - `attempted to rewrite bazel-out path [${from}] to [${toResolve}] but couldn't find the rewrite target` - ); - } - - throw error; - } -} - /** * sass-loader creates about a 40% overhead on the overall optimizer runtime, and * so this constant is used to indicate to assignBundlesToWorkers() that there is @@ -44,6 +31,20 @@ function tryToResolveRewrittenPath(from: string, toResolve: string) { */ const EXTRA_SCSS_WORK_UNITS = 100; +const isBazelPackageCache = new Map(); +function isBazelPackage(pkgJsonPath: string) { + const cached = isBazelPackageCache.get(pkgJsonPath); + if (typeof cached === 'boolean') { + return cached; + } + + const path = parseFilePath(Fs.realpathSync(pkgJsonPath, 'utf-8')); + const match = !!path.matchDirs('bazel-out', /-fastbuild$/, 'bin', 'packages'); + isBazelPackageCache.set(pkgJsonPath, match); + + return match; +} + export class PopulateBundleCachePlugin { constructor(private readonly workerConfig: WorkerConfig, private readonly bundle: Bundle) {} @@ -71,44 +72,16 @@ export class PopulateBundleCachePlugin { let path = getModulePath(module); let parsedPath = parseFilePath(path); - const bazelOut = parsedPath.matchDirs( - 'bazel-out', - /-fastbuild$/, - 'bin', - 'packages', - /.*/, - 'target' - ); - - // if the module is referenced from one of our packages and resolved to the `bazel-out` dir - // we should rewrite our reference to point to the source file so that we can track the - // modified time of that file rather than the built output which is rebuilt all the time - // without actually changing - if (bazelOut) { - const packageDir = parsedPath.dirs[bazelOut.endIndex - 1]; - const subDirs = parsedPath.dirs.slice(bazelOut.endIndex + 1); - path = tryToResolveRewrittenPath( - path, - Path.join( - workerConfig.repoRoot, - 'packages', - packageDir, - 'src', - ...subDirs, - parsedPath.filename - ? Path.basename(parsedPath.filename, Path.extname(parsedPath.filename)) - : '' - ) + const bazelOutIndex = parsedPath.dirs.indexOf('bazel-out'); + if (bazelOutIndex >= 0) { + path = Path.resolve( + this.workerConfig.repoRoot, + ...parsedPath.dirs.slice(bazelOutIndex), + parsedPath.filename ?? '' ); parsedPath = parseFilePath(path); } - if (parsedPath.matchDirs('bazel-out')) { - throw new Error( - `a bazel-out dir is being referenced by module [${path}] and not getting rewritten to its source location` - ); - } - if (!parsedPath.dirs.includes('node_modules')) { referencedFiles.add(path); @@ -125,13 +98,13 @@ export class PopulateBundleCachePlugin { const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); - referencedFiles.add( - Path.join( - parsedPath.root, - ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), - 'package.json' - ) + const pkgJsonPath = Path.join( + parsedPath.root, + ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), + 'package.json' ); + + referencedFiles.add(isBazelPackage(pkgJsonPath) ? path : pkgJsonPath); continue; } diff --git a/packages/kbn-securitysolution-es-utils/src/read_privileges/index.ts b/packages/kbn-securitysolution-es-utils/src/read_privileges/index.ts index 8b11387a1d0208..aab641367339ca 100644 --- a/packages/kbn-securitysolution-es-utils/src/read_privileges/index.ts +++ b/packages/kbn-securitysolution-es-utils/src/read_privileges/index.ts @@ -6,89 +6,69 @@ * Side Public License, v 1. */ -/** - * Copied from src/core/server/elasticsearch/legacy/api_types.ts including its deprecation mentioned below - * TODO: Remove this and refactor the readPrivileges to utilize any newer client side ways rather than all this deprecated legacy stuff - */ -export interface LegacyCallAPIOptions { - /** - * Indicates whether `401 Unauthorized` errors returned from the Elasticsearch API - * should be wrapped into `Boom` error instances with properly set `WWW-Authenticate` - * header that could have been returned by the API itself. If API didn't specify that - * then `Basic realm="Authorization Required"` is used as `WWW-Authenticate`. - */ - wrap401Errors?: boolean; - /** - * A signal object that allows you to abort the request via an AbortController object. - */ - signal?: AbortSignal; -} - -type CallWithRequest, V> = ( - endpoint: string, - params: T, - options?: LegacyCallAPIOptions -) => Promise; +import { ElasticsearchClient } from '../elasticsearch_client'; export const readPrivileges = async ( - callWithRequest: CallWithRequest<{}, unknown>, + esClient: ElasticsearchClient, index: string ): Promise => { - return callWithRequest('transport.request', { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - cluster: [ - 'all', - 'create_snapshot', - 'manage', - 'manage_api_key', - 'manage_ccr', - 'manage_transform', - 'manage_ilm', - 'manage_index_templates', - 'manage_ingest_pipelines', - 'manage_ml', - 'manage_own_api_key', - 'manage_pipeline', - 'manage_rollup', - 'manage_saml', - 'manage_security', - 'manage_token', - 'manage_watcher', - 'monitor', - 'monitor_transform', - 'monitor_ml', - 'monitor_rollup', - 'monitor_watcher', - 'read_ccr', - 'read_ilm', - 'transport_client', - ], - index: [ - { - names: [index], - privileges: [ - 'all', - 'create', - 'create_doc', - 'create_index', - 'delete', - 'delete_index', - 'index', - 'manage', - 'maintenance', - 'manage_follow_index', - 'manage_ilm', - 'manage_leader_index', - 'monitor', - 'read', - 'read_cross_cluster', - 'view_index_metadata', - 'write', - ], - }, - ], - }, - }); + return ( + await esClient.transport.request({ + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + cluster: [ + 'all', + 'create_snapshot', + 'manage', + 'manage_api_key', + 'manage_ccr', + 'manage_transform', + 'manage_ilm', + 'manage_index_templates', + 'manage_ingest_pipelines', + 'manage_ml', + 'manage_own_api_key', + 'manage_pipeline', + 'manage_rollup', + 'manage_saml', + 'manage_security', + 'manage_token', + 'manage_watcher', + 'monitor', + 'monitor_transform', + 'monitor_ml', + 'monitor_rollup', + 'monitor_watcher', + 'read_ccr', + 'read_ilm', + 'transport_client', + ], + index: [ + { + names: [index], + privileges: [ + 'all', + 'create', + 'create_doc', + 'create_index', + 'delete', + 'delete_index', + 'index', + 'manage', + 'maintenance', + 'manage_follow_index', + 'manage_ilm', + 'manage_leader_index', + 'monitor', + 'read', + 'read_cross_cluster', + 'view_index_metadata', + 'write', + ], + }, + ], + }, + }) + ).body; }; diff --git a/src/core/server/status/get_summary_status.test.ts b/src/core/server/status/get_summary_status.test.ts index 0aee718d333cd3..33b2e6f7913a1f 100644 --- a/src/core/server/status/get_summary_status.test.ts +++ b/src/core/server/status/get_summary_status.test.ts @@ -101,15 +101,7 @@ describe('getSummaryStatus', () => { summary: '[s2]: Lorem ipsum', detail: 'See the status page for more information', meta: { - affectedServices: { - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - meta: { - custom: { data: 'here' }, - }, - }, - }, + affectedServices: ['s2'], }, }); }); @@ -136,17 +128,7 @@ describe('getSummaryStatus', () => { detail: 'Vivamus pulvinar sem ac luctus ultrices.', documentationUrl: 'http://helpmenow.com/problem1', meta: { - affectedServices: { - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, - }, - }, + affectedServices: ['s2'], }, }); }); @@ -183,26 +165,7 @@ describe('getSummaryStatus', () => { summary: '[2] services are unavailable', detail: 'See the status page for more information', meta: { - affectedServices: { - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, - }, - s3: { - level: ServiceStatusLevels.unavailable, - summary: 'Proin mattis', - detail: 'Nunc quis nulla at mi lobortis pretium.', - documentationUrl: 'http://helpmenow.com/problem2', - meta: { - other: { data: 'over there' }, - }, - }, - }, + affectedServices: ['s2', 's3'], }, }); }); diff --git a/src/core/server/status/get_summary_status.ts b/src/core/server/status/get_summary_status.ts index 627319d3cd4337..9124023148dd16 100644 --- a/src/core/server/status/get_summary_status.ts +++ b/src/core/server/status/get_summary_status.ts @@ -31,7 +31,7 @@ export const getSummaryStatus = ( // TODO: include URL to status page detail: status.detail ?? `See the status page for more information`, meta: { - affectedServices: { [serviceName]: status }, + affectedServices: [serviceName], }, }; } else { @@ -41,7 +41,7 @@ export const getSummaryStatus = ( // TODO: include URL to status page detail: `See the status page for more information`, meta: { - affectedServices: Object.fromEntries(highestStatuses), + affectedServices: highestStatuses.map(([serviceName]) => serviceName), }, }; } diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index 9dc1ddcddca3e8..a6579069acbc0b 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -303,12 +303,7 @@ describe('PluginStatusService', () => { summary: '[a]: Status check timed out after 30s', detail: 'See the status page for more information', meta: { - affectedServices: { - a: { - level: ServiceStatusLevels.unavailable, - summary: 'Status check timed out after 30s', - }, - }, + affectedServices: ['a'], }, }, }); diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index ed52c35d1becba..4ead81a6638dd6 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -254,12 +254,9 @@ describe('StatusService', () => { "detail": "See the status page for more information", "level": degraded, "meta": Object { - "affectedServices": Object { - "savedObjects": Object { - "level": degraded, - "summary": "This is degraded!", - }, - }, + "affectedServices": Array [ + "savedObjects", + ], }, "summary": "[savedObjects]: This is degraded!", }, @@ -307,12 +304,9 @@ describe('StatusService', () => { "detail": "See the status page for more information", "level": degraded, "meta": Object { - "affectedServices": Object { - "savedObjects": Object { - "level": degraded, - "summary": "This is degraded!", - }, - }, + "affectedServices": Array [ + "savedObjects", + ], }, "summary": "[savedObjects]: This is degraded!", }, diff --git a/src/core/types/elasticsearch/search.ts b/src/core/types/elasticsearch/search.ts index 0960fb189a3412..e8ce9f98501f93 100644 --- a/src/core/types/elasticsearch/search.ts +++ b/src/core/types/elasticsearch/search.ts @@ -39,8 +39,8 @@ type Source = estypes.SearchSourceFilter | boolean | estypes.Fields; type ValueTypeOfField = T extends Record ? ValuesType - : T extends string[] | number[] - ? ValueTypeOfField> + : T extends Array + ? ValueTypeOfField : T extends { field: estypes.Field } ? T['field'] : T extends string | number diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index c7a129418765b4..644dc32dd81409 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -389,6 +389,8 @@ kibana_vars=( xpack.securitySolution.maxTimelineImportExportSize xpack.securitySolution.maxTimelineImportPayloadBytes xpack.securitySolution.packagerTaskInterval + xpack.securitySolution.prebuiltRulesFromFileSystem + xpack.securitySolution.prebuiltRulesFromSavedObjects xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index a71013cb06a88e..9027e3df1e5c2e 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -78,11 +78,7 @@ export const IGNORE_FILE_GLOBS = [ * * @type {Array} */ -export const KEBAB_CASE_DIRECTORY_GLOBS = [ - 'packages/*', - 'x-pack', - 'packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps', -]; +export const KEBAB_CASE_DIRECTORY_GLOBS = ['packages/*', 'x-pack']; /** * These patterns are matched against directories and indicate diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 15497258d45747..51ed25bfc69f66 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -19,6 +19,7 @@ export const storybookAliases = { embeddable: 'src/plugins/embeddable/.storybook', expression_error: 'src/plugins/expression_error/.storybook', expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook', + expression_shape: 'src/plugins/expression_shape/.storybook', infra: 'x-pack/plugins/infra/.storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index e9a5275300ffe9..e665ca44798e32 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -219,8 +219,14 @@ export const useDashboardAppState = ({ const unsavedChanges = current.viewMode === ViewMode.EDIT ? diffDashboardState(lastSaved, current) : {}; + let savedTimeChanged = false; + + /** + * changes to the time filter should only be considered 'unsaved changes' when + * editing the dashboard + */ if (current.viewMode === ViewMode.EDIT) { - const savedTimeChanged = + savedTimeChanged = lastSaved.timeRestore && !areTimeRangesEqual( { @@ -229,9 +235,9 @@ export const useDashboardAppState = ({ }, timefilter.getTime() ); - const hasUnsavedChanges = Object.keys(unsavedChanges).length > 0 || savedTimeChanged; - setDashboardAppState((s) => ({ ...s, hasUnsavedChanges })); } + const hasUnsavedChanges = Object.keys(unsavedChanges).length > 0 || savedTimeChanged; + setDashboardAppState((s) => ({ ...s, hasUnsavedChanges })); unsavedChanges.viewMode = current.viewMode; // always push view mode into session store. dashboardSessionStorage.setState(savedDashboardId, unsavedChanges); diff --git a/src/plugins/data/server/autocomplete/value_suggestions_route.ts b/src/plugins/data/server/autocomplete/value_suggestions_route.ts index 42f2c4d4e6341d..ceddab4d900645 100644 --- a/src/plugins/data/server/autocomplete/value_suggestions_route.ts +++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts @@ -11,7 +11,7 @@ import { IRouter } from 'kibana/server'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { getRequestAbortedSignal } from '../lib'; -import { getKbnServerError } from '../../../kibana_utils/server'; +import { getKbnServerError, reportServerError } from '../../../kibana_utils/server'; import type { ConfigSchema } from '../../config'; import { termsEnumSuggestions } from './terms_enum'; import { termsAggSuggestions } from './terms_agg'; @@ -65,7 +65,8 @@ export function registerValueSuggestionsRoute(router: IRouter, config$: Observab ); return response.ok({ body }); } catch (e) { - throw getKbnServerError(e); + const kbnErr = getKbnServerError(e); + return reportServerError(response, kbnErr); } } ); diff --git a/src/plugins/data/server/search/session/mocks.ts b/src/plugins/data/server/search/session/mocks.ts index 4deaecbf8056d2..ec99853088f784 100644 --- a/src/plugins/data/server/search/session/mocks.ts +++ b/src/plugins/data/server/search/session/mocks.ts @@ -27,7 +27,8 @@ export function createSearchSessionsClientMock(): jest.Mocked< getConfig: jest.fn( () => (({ - defaultExpiration: moment.duration('1', 'm'), + defaultExpiration: moment.duration('1', 'w'), + enabled: true, } as unknown) as SearchSessionsConfigSchema) ), }; diff --git a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts index 91de0fca3674c9..4c75d62f121901 100644 --- a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts @@ -45,11 +45,11 @@ export const eqlSearchStrategyProvider = ( uiSettingsClient ); const params = id - ? getDefaultAsyncGetParams(options) + ? getDefaultAsyncGetParams(null, options) : { ...(await getIgnoreThrottled(uiSettingsClient)), ...defaultParams, - ...getDefaultAsyncGetParams(options), + ...getDefaultAsyncGetParams(null, options), ...request.params, }; const promise = id diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.test.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.test.ts index 56b26a7ebe02cc..7a1ef2fe0a48b5 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.test.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.test.ts @@ -171,7 +171,7 @@ describe('ES search strategy', () => { expect(request.index).toEqual(params.index); expect(request.body).toEqual(params.body); - expect(request).toHaveProperty('keep_alive', '60000ms'); + expect(request).toHaveProperty('keep_alive', '604800000ms'); }); it('makes a GET request to async search without keepalive', async () => { diff --git a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts index d6af00ada80fa6..271032a9e1e270 100644 --- a/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/ese_search/ese_search_strategy.ts @@ -59,7 +59,7 @@ export const enhancedEsSearchStrategyProvider = ( const search = async () => { const params = id - ? getDefaultAsyncGetParams(options) + ? getDefaultAsyncGetParams(searchSessionsClient.getConfig(), options) : { ...(await getDefaultAsyncSubmitParams( uiSettingsClient, diff --git a/src/plugins/data/server/search/strategies/ese_search/request_utils.test.ts b/src/plugins/data/server/search/strategies/ese_search/request_utils.test.ts new file mode 100644 index 00000000000000..272e41e8bf82d4 --- /dev/null +++ b/src/plugins/data/server/search/strategies/ese_search/request_utils.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + getDefaultAsyncSubmitParams, + getDefaultAsyncGetParams, + getIgnoreThrottled, +} from './request_utils'; +import { IUiSettingsClient } from 'kibana/server'; +import { UI_SETTINGS } from '../../../../common'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockUiSettingsClient = (config: Record) => { + return { get: async (key: string) => config[key] } as IUiSettingsClient; +}; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getIgnoreThrottled', () => { + test('returns `ignore_throttled` as `true` when `includeFrozen` is `false`', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const result = await getIgnoreThrottled(mockUiSettingsClient); + expect(result.ignore_throttled).toBe(true); + }); + + test('returns `ignore_throttled` as `false` when `includeFrozen` is `true`', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: true, + }); + const result = await getIgnoreThrottled(mockUiSettingsClient); + expect(result.ignore_throttled).toBe(false); + }); + }); + + describe('getDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const mockConfig = getMockSearchSessionsConfig({}); + const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockUiSettingsClient = getMockUiSettingsClient({ + [UI_SETTINGS.SEARCH_INCLUDE_FROZEN]: false, + }); + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = await getDefaultAsyncSubmitParams(mockUiSettingsClient, mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts index 70da0ba2edcc33..8bf4473355ccf1 100644 --- a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts @@ -46,21 +46,26 @@ export async function getDefaultAsyncSubmitParams( | 'keep_on_completion' > > { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + // TODO: searchSessionsConfig could be "null" if we are running without x-pack which happens only in tests. + // This can be cleaned up when we completely stop separating basic and oss + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + return { + // TODO: adjust for partial results batched_reduce_size: 64, - keep_on_completion: !!options.sessionId, // Always return an ID, even if the request completes quickly - ...getDefaultAsyncGetParams(options), + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, ...(await getIgnoreThrottled(uiSettingsClient)), ...(await getDefaultSearchParams(uiSettingsClient)), - ...(options.sessionId - ? { - // TODO: searchSessionsConfig could be "null" if we are running without x-pack which happens only in tests. - // This can be cleaned up when we completely stop separating basic and oss - keep_alive: searchSessionsConfig - ? `${searchSessionsConfig.defaultExpiration.asMilliseconds()}ms` - : '1m', - } - : {}), + // If search sessions are used, set the initial expiration time. }; } @@ -68,15 +73,20 @@ export async function getDefaultAsyncSubmitParams( @internal */ export function getDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + return { - wait_for_completion_timeout: '100ms', // Wait up to 100ms for the response to return - ...(options.sessionId - ? undefined + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined : { + // We still need to do polling for searches not within the context of a search session or when search session disabled keep_alive: '1m', - // We still need to do polling for searches not within the context of a search session }), }; } diff --git a/src/plugins/expression_shape/.storybook/main.js b/src/plugins/expression_shape/.storybook/main.js new file mode 100644 index 00000000000000..742239e638b8ac --- /dev/null +++ b/src/plugins/expression_shape/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line import/no-commonjs +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/expression_shape/README.md b/src/plugins/expression_shape/README.md new file mode 100755 index 00000000000000..a7e86b6524275c --- /dev/null +++ b/src/plugins/expression_shape/README.md @@ -0,0 +1,9 @@ +# expressionShape + +Expression Shape plugin adds a `shape` function to the expression plugin and an associated renderer. The renderer will display the given shape with selected decorations. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/src/plugins/expression_shape/__fixtures__/function_specs.ts b/src/plugins/expression_shape/__fixtures__/function_specs.ts new file mode 100644 index 00000000000000..c75cb5c61caa1d --- /dev/null +++ b/src/plugins/expression_shape/__fixtures__/function_specs.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { shapeFunction } from '../common/expression_functions'; +import { ExpressionFunction } from '../../../../src/plugins/expressions'; + +export const functionSpecs = [shapeFunction].map((fn) => new ExpressionFunction(fn())); diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts b/src/plugins/expression_shape/__fixtures__/index.ts similarity index 91% rename from packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts rename to src/plugins/expression_shape/__fixtures__/index.ts index b03ee16d2f7463..048c916b21b251 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts +++ b/src/plugins/expression_shape/__fixtures__/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -// stub +export * from './function_specs'; diff --git a/src/plugins/expression_shape/common/constants.ts b/src/plugins/expression_shape/common/constants.ts new file mode 100644 index 00000000000000..ba048e376dabc8 --- /dev/null +++ b/src/plugins/expression_shape/common/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'expressionShape'; +export const PLUGIN_NAME = 'expressionShape'; +export const SVG = 'SVG'; diff --git a/src/plugins/expression_shape/common/expression_functions/index.ts b/src/plugins/expression_shape/common/expression_functions/index.ts new file mode 100644 index 00000000000000..fb19cf244a9ddd --- /dev/null +++ b/src/plugins/expression_shape/common/expression_functions/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { shapeFunction } from './shape_function'; diff --git a/src/plugins/expression_shape/common/expression_functions/shape_function.ts b/src/plugins/expression_shape/common/expression_functions/shape_function.ts new file mode 100644 index 00000000000000..8ee11c937599ff --- /dev/null +++ b/src/plugins/expression_shape/common/expression_functions/shape_function.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { ExpressionShapeFunction, Shape } from '../types'; +import { SVG } from '../constants'; +import { getAvailableShapes } from '../lib'; + +export const strings = { + help: i18n.translate('expressionShape.functions.shapeHelpText', { + defaultMessage: 'Creates a shape.', + }), + args: { + shape: i18n.translate('expressionShape.functions.shape.args.shapeHelpText', { + defaultMessage: 'Pick a shape.', + }), + border: i18n.translate('expressionShape.functions.shape.args.borderHelpText', { + defaultMessage: 'An {SVG} color for the border outlining the shape.', + values: { + SVG, + }, + }), + borderWidth: i18n.translate('expressionShape.functions.shape.args.borderWidthHelpText', { + defaultMessage: 'The thickness of the border.', + }), + fill: i18n.translate('expressionShape.functions.shape.args.fillHelpText', { + defaultMessage: 'An {SVG} color to fill the shape.', + values: { + SVG, + }, + }), + maintainAspect: i18n.translate('expressionShape.functions.shape.args.maintainAspectHelpText', { + defaultMessage: `Maintain the shape's original aspect ratio?`, + }), + }, +}; + +export const errors = { + invalidShape: (shape: string) => + new Error( + i18n.translate('expressionShape.functions.shape.invalidShapeErrorMessage', { + defaultMessage: "Invalid value: '{shape}'. Such a shape doesn't exist.", + values: { + shape, + }, + }) + ), +}; + +export const shapeFunction: ExpressionShapeFunction = () => { + const { help, args: argHelp } = strings; + + return { + name: 'shape', + aliases: [], + inputTypes: ['null'], + help, + args: { + shape: { + types: ['string'], + help: argHelp.shape, + aliases: ['_'], + default: 'square', + options: Object.values(Shape), + }, + border: { + types: ['string'], + aliases: ['stroke'], + help: argHelp.border, + }, + borderWidth: { + types: ['number'], + aliases: ['strokeWidth'], + help: argHelp.borderWidth, + default: 0, + }, + fill: { + types: ['string'], + help: argHelp.fill, + default: 'black', + }, + maintainAspect: { + types: ['boolean'], + help: argHelp.maintainAspect, + default: false, + options: [true, false], + }, + }, + fn: (input, args) => { + const avaliableShapes = getAvailableShapes(); + if (!avaliableShapes.includes(args.shape)) { + throw errors.invalidShape(args.shape); + } + + return { + type: 'shape', + ...args, + }; + }, + }; +}; diff --git a/src/plugins/expression_shape/common/index.ts b/src/plugins/expression_shape/common/index.ts new file mode 100755 index 00000000000000..7a56f49eb38cbf --- /dev/null +++ b/src/plugins/expression_shape/common/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './constants'; +export * from './types'; + +export { getAvailableShapes } from './lib/available_shapes'; diff --git a/src/plugins/expression_shape/common/lib/available_shapes.ts b/src/plugins/expression_shape/common/lib/available_shapes.ts new file mode 100644 index 00000000000000..a8883f76e0c98f --- /dev/null +++ b/src/plugins/expression_shape/common/lib/available_shapes.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Shape } from '../types'; + +export const getAvailableShapes = () => Object.values(Shape); diff --git a/src/plugins/expression_shape/common/lib/index.ts b/src/plugins/expression_shape/common/lib/index.ts new file mode 100644 index 00000000000000..d62a0d96078be5 --- /dev/null +++ b/src/plugins/expression_shape/common/lib/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './view_box'; +export * from './available_shapes'; diff --git a/src/plugins/expression_shape/common/lib/view_box.ts b/src/plugins/expression_shape/common/lib/view_box.ts new file mode 100644 index 00000000000000..3028d7a846ed4c --- /dev/null +++ b/src/plugins/expression_shape/common/lib/view_box.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ParentNodeParams, ViewBoxParams } from '../types'; + +export function viewBoxToString(viewBox?: ViewBoxParams): undefined | string { + if (!viewBox) return; + return `${viewBox?.minX} ${viewBox?.minY} ${viewBox?.width} ${viewBox?.height}`; +} + +function getMinxAndWidth(viewBoxParams: ViewBoxParams, { borderOffset, width }: ParentNodeParams) { + let { minX, width: shapeWidth } = viewBoxParams; + if (width) { + const xOffset = (shapeWidth / width) * borderOffset; + minX -= xOffset; + shapeWidth += xOffset * 2; + } else { + shapeWidth = 0; + } + + return [minX, shapeWidth]; +} + +function getMinyAndHeight( + viewBoxParams: ViewBoxParams, + { borderOffset, height }: ParentNodeParams +) { + let { minY, height: shapeHeight } = viewBoxParams; + if (height) { + const yOffset = (shapeHeight / height) * borderOffset; + minY -= yOffset; + shapeHeight += yOffset * 2; + } else { + shapeHeight = 0; + } + + return [minY, shapeHeight]; +} + +export function getViewBox( + viewBoxParams: ViewBoxParams, + parentNodeParams: ParentNodeParams +): ViewBoxParams { + const [minX, width] = getMinxAndWidth(viewBoxParams, parentNodeParams); + const [minY, height] = getMinyAndHeight(viewBoxParams, parentNodeParams); + return { minX, minY, width, height }; +} diff --git a/src/plugins/expression_shape/common/types/expression_functions.ts b/src/plugins/expression_shape/common/types/expression_functions.ts new file mode 100644 index 00000000000000..4f0fad62fde049 --- /dev/null +++ b/src/plugins/expression_shape/common/types/expression_functions.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; + +export enum Shape { + ARROW = 'arrow', + ARROW_MULTI = 'arrowMulti', + BOOKMARK = 'bookmark', + CIRCLE = 'circle', + CROSS = 'cross', + HEXAGON = 'hexagon', + KITE = 'kite', + PENTAGON = 'pentagon', + RHOMBUS = 'rhombus', + SEMICIRCLE = 'semicircle', + SPEECH_BUBBLE = 'speechBubble', + SQUARE = 'square', + STAR = 'star', + TAG = 'tag', + TRIANGLE = 'triangle', + TRIANGLE_RIGHT = 'triangleRight', +} + +interface Arguments { + border: string; + borderWidth: number; + shape: Shape; + fill: string; + maintainAspect: boolean; +} + +export interface Output extends Arguments { + type: 'shape'; +} + +export type ExpressionShapeFunction = () => ExpressionFunctionDefinition< + 'shape', + number | null, + Arguments, + Output +>; diff --git a/src/plugins/expression_shape/common/types/expression_renderers.ts b/src/plugins/expression_shape/common/types/expression_renderers.ts new file mode 100644 index 00000000000000..c61d8292ddff65 --- /dev/null +++ b/src/plugins/expression_shape/common/types/expression_renderers.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { Shape } from './expression_functions'; + +export type OriginString = 'bottom' | 'left' | 'top' | 'right'; +export interface ShapeRendererConfig { + border: string; + borderWidth: number; + shape: Shape; + fill: string; + maintainAspect: boolean; +} + +export interface NodeDimensions { + width: number; + height: number; +} + +export interface ParentNodeParams { + borderOffset: number; + width: number; + height: number; +} + +export interface ViewBoxParams { + minX: number; + minY: number; + width: number; + height: number; +} diff --git a/src/plugins/expression_shape/common/types/index.ts b/src/plugins/expression_shape/common/types/index.ts new file mode 100644 index 00000000000000..ec934e7affe88b --- /dev/null +++ b/src/plugins/expression_shape/common/types/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +export * from './expression_functions'; +export * from './expression_renderers'; diff --git a/src/plugins/expression_shape/jest.config.js b/src/plugins/expression_shape/jest.config.js new file mode 100644 index 00000000000000..a390c0154bbd0b --- /dev/null +++ b/src/plugins/expression_shape/jest.config.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/expression_shape'], +}; diff --git a/src/plugins/expression_shape/kibana.json b/src/plugins/expression_shape/kibana.json new file mode 100755 index 00000000000000..1a868288a2df8c --- /dev/null +++ b/src/plugins/expression_shape/kibana.json @@ -0,0 +1,13 @@ +{ + "id": "expressionShape", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "extraPublicDirs": [ + "common" + ], + "requiredPlugins": ["expressions", "presentationUtil"], + "optionalPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/expression_shape/public/components/reusable/index.tsx b/src/plugins/expression_shape/public/components/reusable/index.tsx new file mode 100644 index 00000000000000..0e78432a0ceb4b --- /dev/null +++ b/src/plugins/expression_shape/public/components/reusable/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './shape_drawer'; +export * from './utils'; +export * from './types'; diff --git a/src/plugins/expression_shape/public/components/reusable/shape_drawer.tsx b/src/plugins/expression_shape/public/components/reusable/shape_drawer.tsx new file mode 100644 index 00000000000000..b976dfad389b3b --- /dev/null +++ b/src/plugins/expression_shape/public/components/reusable/shape_drawer.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { forwardRef, Ref, useImperativeHandle } from 'react'; +import { ShapeDrawerProps, ShapeRef } from './types'; + +function ShapeDrawerComponent(props: ShapeDrawerProps, ref: Ref) { + const { shapeType, getShape } = props; + const Shape = getShape(shapeType); + + if (!Shape) throw new Error("Shape doesn't exist."); + + useImperativeHandle(ref, () => ({ getData: () => Shape.data }), [Shape]); + + return ; +} + +export const ShapeDrawer = forwardRef(ShapeDrawerComponent); diff --git a/src/plugins/expression_shape/public/components/reusable/shape_factory.tsx b/src/plugins/expression_shape/public/components/reusable/shape_factory.tsx new file mode 100644 index 00000000000000..9e775616726bfc --- /dev/null +++ b/src/plugins/expression_shape/public/components/reusable/shape_factory.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { viewBoxToString } from '../../../common/lib'; +import { ShapeProps, SvgConfig, SvgElementTypes } from './types'; + +const getShapeComponent = (svgParams: SvgConfig) => + function Shape({ shapeAttributes, shapeContentAttributes }: ShapeProps) { + const { viewBox: initialViewBox, shapeProps, shapeType } = svgParams; + + const viewBox = shapeAttributes?.viewBox + ? viewBoxToString(shapeAttributes?.viewBox) + : viewBoxToString(initialViewBox); + + const SvgContentElement = getShapeContentElement(shapeType); + return ( + + + + ); + }; + +function getShapeContentElement(type?: SvgElementTypes) { + switch (type) { + case SvgElementTypes.circle: + return (props: SvgConfig['shapeProps']) => ; + case SvgElementTypes.rect: + return (props: SvgConfig['shapeProps']) => ; + case SvgElementTypes.path: + return (props: SvgConfig['shapeProps']) => ; + default: + return (props: SvgConfig['shapeProps']) => ; + } +} + +export const createShape = (props: SvgConfig) => { + return { + Component: getShapeComponent(props), + data: props, + }; +}; + +export type ShapeType = ReturnType; diff --git a/src/plugins/expression_shape/public/components/reusable/types.tsx b/src/plugins/expression_shape/public/components/reusable/types.tsx new file mode 100644 index 00000000000000..f779633e08a87a --- /dev/null +++ b/src/plugins/expression_shape/public/components/reusable/types.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Ref, SVGProps } from 'react'; +import { ViewBoxParams } from '../../../common/types'; +import type { ShapeType } from './shape_factory'; + +export interface ShapeProps { + shapeAttributes?: ShapeAttributes; + shapeContentAttributes?: ShapeContentAttributes; +} + +export enum SvgElementTypes { + polygon, + circle, + rect, + path, +} + +export interface ShapeAttributes { + fill?: SVGProps['fill']; + stroke?: SVGProps['stroke']; + width?: SVGProps['width']; + height?: SVGProps['height']; + viewBox?: ViewBoxParams; + overflow?: SVGProps['overflow']; + preserveAspectRatio?: SVGProps['preserveAspectRatio']; +} + +export interface ShapeContentAttributes { + strokeWidth?: SVGProps['strokeWidth']; + stroke?: SVGProps['stroke']; + fill?: SVGProps['fill']; + vectorEffect?: SVGProps['vectorEffect']; + strokeMiterlimit?: SVGProps['strokeMiterlimit']; +} + +interface CircleParams { + r: SVGProps['r']; + cx: SVGProps['cx']; + cy: SVGProps['cy']; +} + +interface RectParams { + x: SVGProps['x']; + y: SVGProps['y']; + width: SVGProps['width']; + height: SVGProps['height']; +} + +interface PathParams { + d: SVGProps['d']; +} + +interface PolygonParams { + points?: SVGProps['points']; + strokeLinejoin?: SVGProps['strokeLinejoin']; +} + +type SpecificShapeContentAttributes = CircleParams | RectParams | PathParams | PolygonParams; + +export interface SvgConfig { + shapeType?: SvgElementTypes; + viewBox: ViewBoxParams; + shapeProps: ShapeContentAttributes & SpecificShapeContentAttributes; +} + +export type ShapeDrawerProps = { + shapeType: string; + getShape: (shapeType: string) => ShapeType | undefined; + ref: Ref; +} & ShapeProps; + +export interface ShapeRef { + getData: () => SvgConfig; +} + +export type { ShapeType }; diff --git a/src/plugins/expression_shape/public/components/reusable/utils.ts b/src/plugins/expression_shape/public/components/reusable/utils.ts new file mode 100644 index 00000000000000..72fed421dabe05 --- /dev/null +++ b/src/plugins/expression_shape/public/components/reusable/utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SvgConfig } from './types'; + +export const getDefaultShapeData = (): SvgConfig => ({ + viewBox: { + minX: 0, + minY: 0, + width: 0, + height: 0, + }, + shapeProps: {}, +}); diff --git a/src/plugins/expression_shape/public/components/shape/index.ts b/src/plugins/expression_shape/public/components/shape/index.ts new file mode 100644 index 00000000000000..12c852090ab4d9 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { lazy } from 'react'; + +export const LazyShapeComponent = lazy(() => import('./shape_component')); +export const LazyShapeDrawer = lazy(() => import('./shape_drawer')); diff --git a/src/plugins/expression_shape/public/components/shape/shape_component.tsx b/src/plugins/expression_shape/public/components/shape/shape_component.tsx new file mode 100644 index 00000000000000..9e7aba33f5834c --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shape_component.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState, useEffect, useCallback, RefCallback } from 'react'; +import { useResizeObserver } from '@elastic/eui'; +import { withSuspense } from '../../../../presentation_util/public'; +import { + ShapeRef, + ShapeAttributes, + ShapeContentAttributes, + SvgConfig, + getDefaultShapeData, +} from '../reusable'; +import { Dimensions, ShapeComponentProps } from './types'; +import { getViewBox } from '../../../common/lib'; +import { LazyShapeDrawer } from '../..'; + +const ShapeDrawer = withSuspense(LazyShapeDrawer); + +function ShapeComponent({ + onLoaded, + parentNode, + shape: shapeType, + fill, + border, + borderWidth, + maintainAspect, +}: ShapeComponentProps) { + const parentNodeDimensions = useResizeObserver(parentNode); + const [dimensions, setDimensions] = useState({ + width: parentNode.offsetWidth, + height: parentNode.offsetHeight, + }); + const [shapeData, setShapeData] = useState(getDefaultShapeData()); + + useEffect(() => { + setDimensions({ + width: parentNode.offsetWidth, + height: parentNode.offsetHeight, + }); + onLoaded(); + }, [parentNode, parentNodeDimensions, onLoaded]); + + const shapeRef = useCallback>((node) => { + if (node !== null) setShapeData(node.getData()); + }, []); + + const strokeWidth = Math.max(borderWidth, 0); + + const shapeContentAttributes: ShapeContentAttributes = { + strokeWidth: String(strokeWidth), + vectorEffect: 'non-scaling-stroke', + strokeMiterlimit: '999', + }; + if (fill) shapeContentAttributes.fill = fill; + if (border) shapeContentAttributes.stroke = border; + + const { width, height } = dimensions; + + const shapeAttributes: ShapeAttributes = { + width, + height, + overflow: 'visible', + preserveAspectRatio: maintainAspect ? 'xMidYMid meet' : 'none', + viewBox: getViewBox(shapeData.viewBox, { + borderOffset: strokeWidth, + width, + height, + }), + }; + + parentNode.style.lineHeight = '0'; + + return ( +
+ +
+ ); +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { ShapeComponent as default }; diff --git a/src/plugins/expression_shape/public/components/shape/shape_drawer.tsx b/src/plugins/expression_shape/public/components/shape/shape_drawer.tsx new file mode 100644 index 00000000000000..90906c203332a7 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shape_drawer.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { Ref } from 'react'; +import { ShapeDrawer, ShapeRef } from '../reusable'; +import { getShape } from './shapes'; +import { ShapeDrawerComponentProps } from './types'; + +const ShapeDrawerComponent = React.forwardRef( + (props: ShapeDrawerComponentProps, ref: Ref) => ( + + ) +); + +// eslint-disable-next-line import/no-default-export +export { ShapeDrawerComponent as default }; diff --git a/src/plugins/expression_shape/public/components/shape/shapes/arrow.tsx b/src/plugins/expression_shape/public/components/shape/shapes/arrow.tsx new file mode 100644 index 00000000000000..9ae5dafaed0eef --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/arrow.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { createShape } from '../../reusable/shape_factory'; + +export const Arrow = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '0,40 60,40 60,20 95,50 60,80 60,60 0,60', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/arrow_multi.tsx b/src/plugins/expression_shape/public/components/shape/shapes/arrow_multi.tsx new file mode 100644 index 00000000000000..d8ba48fe1a9248 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/arrow_multi.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { createShape } from '../../reusable/shape_factory'; + +export const ArrowMulti = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 60, + }, + shapeProps: { + points: '5,30 25,10 25,20 75,20 75,10 95,30 75,50 75,40 25,40 25,50', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/bookmark.tsx b/src/plugins/expression_shape/public/components/shape/shapes/bookmark.tsx new file mode 100644 index 00000000000000..47e60a4c6a2455 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/bookmark.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Bookmark = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 60, + height: 100, + }, + shapeProps: { + points: '0,0 60,0 60,95 30,75 0,95 0,0', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/circle.tsx b/src/plugins/expression_shape/public/components/shape/shapes/circle.tsx new file mode 100644 index 00000000000000..271efc2ea6c455 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/circle.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; +import { SvgElementTypes } from '../../reusable/types'; + +export const Circle = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + r: '45', + cx: '50', + cy: '50', + }, + shapeType: SvgElementTypes.circle, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/cross.tsx b/src/plugins/expression_shape/public/components/shape/shapes/cross.tsx new file mode 100644 index 00000000000000..8e85860f850994 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/cross.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Cross = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '30,0 70,0 70,30 100,30 100,70 70,70 70,100 30,100 30,70 0,70 0,30 30,30', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/hexagon.tsx b/src/plugins/expression_shape/public/components/shape/shapes/hexagon.tsx new file mode 100644 index 00000000000000..b51b48c93c9e1b --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/hexagon.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Hexagon = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: + '70.000, 15.359 30.000, 15.359 10.000, 50.000 30.000, 84.641 70.000, 84.641 90.000, 50.000', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/index.ts b/src/plugins/expression_shape/public/components/shape/shapes/index.ts new file mode 100644 index 00000000000000..90a47ea722600f --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Arrow as arrow } from './arrow'; +import { ArrowMulti as arrowMulti } from './arrow_multi'; +import { Bookmark as bookmark } from './bookmark'; +import { Cross as cross } from './cross'; +import { Circle as circle } from './circle'; +import { Hexagon as hexagon } from './hexagon'; +import { Kite as kite } from './kite'; +import { Pentagon as pentagon } from './pentagon'; +import { Rhombus as rhombus } from './rhombus'; +import { Semicircle as semicircle } from './semicircle'; +import { SpeechBubble as speechBubble } from './speech_bubble'; +import { Square as square } from './square'; +import { Star as star } from './star'; +import { Tag as tag } from './tag'; +import { Triangle as triangle } from './triangle'; +import { TriangleRight as triangleRight } from './triangle_right'; +import { ShapeType } from '../../reusable'; + +const shapes: { [key: string]: ShapeType } = { + arrow, + arrowMulti, + bookmark, + cross, + circle, + hexagon, + kite, + pentagon, + rhombus, + semicircle, + speechBubble, + square, + star, + tag, + triangle, + triangleRight, +}; + +export const getShape = (shapeType: string) => shapes[shapeType]; diff --git a/src/plugins/expression_shape/public/components/shape/shapes/kite.tsx b/src/plugins/expression_shape/public/components/shape/shapes/kite.tsx new file mode 100644 index 00000000000000..52dde23dee4f6e --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/kite.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Kite = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 150, + }, + shapeProps: { + points: '50,10 10,50 50,140 90,50', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/pentagon.tsx b/src/plugins/expression_shape/public/components/shape/shapes/pentagon.tsx new file mode 100644 index 00000000000000..f26f9808a0f1ee --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/pentagon.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Pentagon = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '50.0000, 14.0000 11.9577, 41.6393 26.4886, 86.3607 73.5114, 86.3607 88.0423, 41.6393', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/rhombus.tsx b/src/plugins/expression_shape/public/components/shape/shapes/rhombus.tsx new file mode 100644 index 00000000000000..6ef967bf6e5d79 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/rhombus.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Rhombus = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '50,10 10,50 50,90 90,50', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/semicircle.tsx b/src/plugins/expression_shape/public/components/shape/shapes/semicircle.tsx new file mode 100644 index 00000000000000..a8a088bd86a768 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/semicircle.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; +import { SvgElementTypes } from '../../reusable/types'; + +export const Semicircle = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + d: 'M 5,50 h 90 A 45 45 180 1 0 5,50 Z', + }, + shapeType: SvgElementTypes.path, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/speech_bubble.tsx b/src/plugins/expression_shape/public/components/shape/shapes/speech_bubble.tsx new file mode 100644 index 00000000000000..5465897207eaa3 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/speech_bubble.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const SpeechBubble = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '0,0 100,0 100,70 40,70 20,85 25,70 0,70', + strokeLinejoin: 'round', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/square.tsx b/src/plugins/expression_shape/public/components/shape/shapes/square.tsx new file mode 100644 index 00000000000000..1fb582e69139f3 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/square.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; +import { SvgElementTypes } from '../../reusable/types'; + +export const Square = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + x: '0', + y: '0', + width: '100', + height: '100', + }, + shapeType: SvgElementTypes.rect, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/star.tsx b/src/plugins/expression_shape/public/components/shape/shapes/star.tsx new file mode 100644 index 00000000000000..a3ccff0387e8a5 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/star.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Star = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: + '41.183, 37.865 12.652, 37.865 35.734, 54.635 26.917, 81.771 50.000, 65.000 73.265, 81.904 64.266, 54.635 87.348, 37.865 58.817, 37.865 50.07, 10.515', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/tag.tsx b/src/plugins/expression_shape/public/components/shape/shapes/tag.tsx new file mode 100644 index 00000000000000..4c6cdac23b1e04 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/tag.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Tag = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 60, + }, + shapeProps: { + points: '0,0 75,0 90,30 75,60 0,60', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/triangle.tsx b/src/plugins/expression_shape/public/components/shape/shapes/triangle.tsx new file mode 100644 index 00000000000000..b8823bd9043509 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/triangle.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const Triangle = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '50.000, 20.000 15.359, 80.000 84.641, 80.000', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/shapes/triangle_right.tsx b/src/plugins/expression_shape/public/components/shape/shapes/triangle_right.tsx new file mode 100644 index 00000000000000..6d054263681956 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/shapes/triangle_right.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createShape } from '../../reusable/shape_factory'; + +export const TriangleRight = createShape({ + viewBox: { + minX: 0, + minY: 0, + width: 100, + height: 100, + }, + shapeProps: { + points: '0, 10 0, 100 90, 100', + }, +}); diff --git a/src/plugins/expression_shape/public/components/shape/types.ts b/src/plugins/expression_shape/public/components/shape/types.ts new file mode 100644 index 00000000000000..d99d4c386db531 --- /dev/null +++ b/src/plugins/expression_shape/public/components/shape/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { IInterpreterRenderHandlers } from '../../../../../../src/plugins/expressions'; +import { ShapeRendererConfig } from '../../../common/types'; +import { ShapeDrawerProps } from '../reusable/types'; + +export interface ShapeComponentProps extends ShapeRendererConfig { + onLoaded: IInterpreterRenderHandlers['done']; + parentNode: HTMLElement; +} + +export interface Dimensions { + width: number; + height: number; +} +export type ShapeDrawerComponentProps = Omit; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/__stories__/__snapshots__/shape.stories.storyshot b/src/plugins/expression_shape/public/expression_renderers/__stories__/__snapshots__/shape.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/__stories__/__snapshots__/shape.stories.storyshot rename to src/plugins/expression_shape/public/expression_renderers/__stories__/__snapshots__/shape.stories.storyshot diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/__stories__/shape.stories.tsx b/src/plugins/expression_shape/public/expression_renderers/__stories__/shape_renderer.stories.tsx similarity index 59% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/__stories__/shape.stories.tsx rename to src/plugins/expression_shape/public/expression_renderers/__stories__/shape_renderer.stories.tsx index ab8d517c67114a..10ac3df88e81cd 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/__stories__/shape.stories.tsx +++ b/src/plugins/expression_shape/public/expression_renderers/__stories__/shape_renderer.stories.tsx @@ -1,15 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { shape } from '../'; -import { Render } from '../../__stories__/render'; -import { Shape } from '../../../functions/common/shape'; +import { shapeRenderer as shape } from '../'; +import { Render } from '../../../../presentation_util/public/__stories__'; +import { Shape } from '../../../common/types'; storiesOf('renderers/shape', module).add('default', () => { const config = { diff --git a/src/plugins/expression_shape/public/expression_renderers/index.ts b/src/plugins/expression_shape/public/expression_renderers/index.ts new file mode 100644 index 00000000000000..7dba64d7728dba --- /dev/null +++ b/src/plugins/expression_shape/public/expression_renderers/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { shapeRenderer } from './shape_renderer'; + +export const renderers = [shapeRenderer]; + +export { shapeRenderer }; diff --git a/src/plugins/expression_shape/public/expression_renderers/shape_renderer.tsx b/src/plugins/expression_shape/public/expression_renderers/shape_renderer.tsx new file mode 100644 index 00000000000000..09a0fe53fbe744 --- /dev/null +++ b/src/plugins/expression_shape/public/expression_renderers/shape_renderer.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { i18n } from '@kbn/i18n'; +import { withSuspense } from '../../../presentation_util/public'; +import { ShapeRendererConfig } from '../../common/types'; +import { LazyShapeComponent } from '../components/shape'; + +const strings = { + getDisplayName: () => + i18n.translate('expressionShape.renderer.shape.displayName', { + defaultMessage: 'Shape', + }), + getHelpDescription: () => + i18n.translate('expressionShape.renderer.shape.helpDescription', { + defaultMessage: 'Render a basic shape', + }), +}; + +const ShapeComponent = withSuspense(LazyShapeComponent); + +export const shapeRenderer = (): ExpressionRenderDefinition => ({ + name: 'shape', + displayName: strings.getDisplayName(), + help: strings.getHelpDescription(), + reuseDomNode: true, + render: async ( + domNode: HTMLElement, + config: ShapeRendererConfig, + handlers: IInterpreterRenderHandlers + ) => { + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render( + + + , + domNode + ); + }, +}); diff --git a/src/plugins/expression_shape/public/index.ts b/src/plugins/expression_shape/public/index.ts new file mode 100755 index 00000000000000..c5933a8cd06ed0 --- /dev/null +++ b/src/plugins/expression_shape/public/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionShapePlugin } from './plugin'; + +export type { ExpressionShapePluginSetup, ExpressionShapePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionShapePlugin(); +} + +export * from './expression_renderers'; +export { LazyShapeDrawer } from './components/shape'; +export { getDefaultShapeData } from './components/reusable'; +export * from './components/shape/types'; +export * from './components/reusable/types'; +export * from '../common/types'; diff --git a/src/plugins/expression_shape/public/plugin.ts b/src/plugins/expression_shape/public/plugin.ts new file mode 100755 index 00000000000000..cb28f97acd697c --- /dev/null +++ b/src/plugins/expression_shape/public/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; +import { shapeRenderer } from './expression_renderers'; + +interface SetupDeps { + expressions: ExpressionsSetup; +} + +interface StartDeps { + expression: ExpressionsStart; +} + +export type ExpressionShapePluginSetup = void; +export type ExpressionShapePluginStart = void; + +export class ExpressionShapePlugin + implements Plugin { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionShapePluginSetup { + expressions.registerRenderer(shapeRenderer); + } + + public start(core: CoreStart): ExpressionShapePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_shape/server/index.ts b/src/plugins/expression_shape/server/index.ts new file mode 100644 index 00000000000000..79da7a954c5501 --- /dev/null +++ b/src/plugins/expression_shape/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExpressionShapePlugin } from './plugin'; + +export type { ExpressionShapePluginSetup, ExpressionShapePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionShapePlugin(); +} diff --git a/src/plugins/expression_shape/server/plugin.ts b/src/plugins/expression_shape/server/plugin.ts new file mode 100644 index 00000000000000..c03acaa04f1ecc --- /dev/null +++ b/src/plugins/expression_shape/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { ExpressionsServerStart, ExpressionsServerSetup } from '../../expressions/server'; +import { shapeFunction } from '../common/expression_functions'; + +interface SetupDeps { + expressions: ExpressionsServerSetup; +} + +interface StartDeps { + expression: ExpressionsServerStart; +} + +export type ExpressionShapePluginSetup = void; +export type ExpressionShapePluginStart = void; + +export class ExpressionShapePlugin + implements Plugin { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionShapePluginSetup { + expressions.registerFunction(shapeFunction); + } + + public start(core: CoreStart): ExpressionShapePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_shape/tsconfig.json b/src/plugins/expression_shape/tsconfig.json new file mode 100644 index 00000000000000..5fab51496c97e7 --- /dev/null +++ b/src/plugins/expression_shape/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "__fixtures__/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + ] +} diff --git a/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png index 2d92260e43a92c..37214a219a377e 100644 Binary files a/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png and b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard.png differ diff --git a/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png index 5c8355297a4eca..0c0e5b6f012053 100644 Binary files a/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png and b/src/plugins/home/public/assets/sample_data_resources/ecommerce/dashboard_dark.png differ diff --git a/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png index 50d0363d241bdb..a7b66ceeb288d8 100644 Binary files a/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png and b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard.png differ diff --git a/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png index c047639a07fe14..18487d5c44bfff 100644 Binary files a/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png and b/src/plugins/home/public/assets/sample_data_resources/flights/dashboard_dark.png differ diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts index 267769d33fba2c..971b426458ce4a 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/saved_objects.ts @@ -12,104 +12,20 @@ import { i18n } from '@kbn/i18n'; import { SavedObject } from 'kibana/server'; export const getSavedObjects = (): SavedObject[] => [ - { - id: '37cc8650-b882-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.salesByCategoryTitle', { - defaultMessage: '[eCommerce] Sales by Category', - }), - visState: - '{"title":"[eCommerce] Sales by Category","type":"area","params":{"type":"area","grid":{"categoryLines":false,"style":{"color":"#eee"}},"categoryAxes":[{"id":"CategoryAxis-1","type":"category","position":"bottom","show":true,"style":{},"scale":{"type":"linear"},"labels":{"show":true,"truncate":100,"filter":true},"title":{}}],"valueAxes":[{"id":"ValueAxis-1","name":"LeftAxis-1","type":"value","position":"left","show":true,"style":{},"scale":{"type":"linear","mode":"normal"},"labels":{"show":true,"rotate":0,"filter":false,"truncate":100},"title":{"text":"Sum of total_quantity"}}],"seriesParams":[{"show":"true","type":"area","mode":"stacked","data":{"label":"Sum of total_quantity","id":"1"},"drawLinesBetweenPoints":true,"showCircles":true,"interpolate":"linear","valueAxis":"ValueAxis-1"}],"addTooltip":true,"addLegend":true,"legendPosition":"top","times":[],"addTimeMarker":false,"detailedTooltip":true,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"sum","schema":"metric","params":{"field":"total_quantity"}},{"id":"2","enabled":true,"type":"date_histogram","schema":"segment","params":{"field":"order_date","interval":"auto","drop_partials":false,"min_doc_count":1,"extended_bounds":{}}},{"id":"3","enabled":true,"type":"terms","schema":"group","params":{"field":"category.keyword","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, - { - id: 'ed8436b0-b88b-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.salesByGenderTitle', { - defaultMessage: '[eCommerce] Sales by Gender', - }), - visState: - '{"title":"[eCommerce] Sales by Gender","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":true,"values":true,"last_level":true,"truncate":100},"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"customer_gender","size":5,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, - { - id: '09ffee60-b88c-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.markdownTitle', { - defaultMessage: '[eCommerce] Markdown', - }), - visState: - '{"title":"[eCommerce] Markdown","type":"markdown","params":{"fontSize":12,"openLinksInNewTab":false,"markdown":"### Sample eCommerce Data\\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html)."},"aggs":[]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, - { - id: '1c389590-b88d-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.controlsTitle', { - defaultMessage: '[eCommerce] Controls', - }), - visState: - '{"title":"[eCommerce] Controls","type":"input_control_vis","params":{"controls":[{"id":"1536977437774","indexPattern":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","fieldName":"manufacturer.keyword","parent":"","label":"Manufacturer","type":"list","options":{"type":"terms","multiselect":true,"dynamicOptions":true,"size":5,"order":"desc"}},{"id":"1536977465554","indexPattern":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","fieldName":"category.keyword","parent":"","label":"Category","type":"list","options":{"type":"terms","multiselect":true,"dynamicOptions":true,"size":5,"order":"desc"}},{"id":"1536977596163","indexPattern":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","fieldName":"total_quantity","parent":"","label":"Quantity","type":"range","options":{"decimalPlaces":0,"step":1}}],"updateFiltersOnChange":false,"useTimeFilter":true,"pinFilters":false},"aggs":[]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: '{"query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, { id: '45e07720-b890-11e8-a6d9-e546fe2bba5f', type: 'visualization', - updated_at: '2018-10-01T15:17:30.755Z', - version: '2', - migrationVersion: {}, + updated_at: '2021-07-16T20:14:25.894Z', + version: '3', + migrationVersion: { + visualization: '7.14.0', + }, attributes: { title: i18n.translate('home.sampleData.ecommerceSpec.promotionTrackingTitle', { defaultMessage: '[eCommerce] Promotion Tracking', }), visState: - '{"title":"[eCommerce] Promotion Tracking","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(240,138,217,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*trouser*","label":"Revenue Trousers","value_template":"${{value}}"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(191,240,129,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*watch*","label":"Revenue Watches","value_template":"${{value}}"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(23,233,230,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*bag*","label":"Revenue Bags","value_template":"${{value}}"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(235,186,180,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":"0.7","stacked":"none","filter":"products.product_name:*cocktail dress*","label":"Revenue Cocktail Dresses","value_template":"${{value}}"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":">=12h","use_kibana_indexes":true,"axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","index_pattern_ref_name":"ref_2_index_pattern","query_string":"taxful_total_price:>250","id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1}]},"aggs":[]}', + '{"title":"[eCommerce] Promotion Tracking","type":"metrics","aggs":[],"params":{"time_range_mode":"entire_time_range","id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"timeseries","series":[{"id":"ea20ae70-b88d-11e8-a451-f37365e9f268","color":"rgba(211,96,134,1)","split_mode":"everything","metrics":[{"id":"ea20ae71-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":"2","point_size":"5","fill":"0","stacked":"none","filter":{"query":"products.product_name:*trouser*","language":"lucene"},"label":"Revenue Trousers","value_template":"${{value}}","split_color_mode":"gradient"},{"id":"062d77b0-b88e-11e8-a451-f37365e9f268","color":"rgba(84,179,153,1)","split_mode":"everything","metrics":[{"id":"062d77b1-b88e-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":"2","point_size":"05","fill":"0","stacked":"none","filter":{"query":"products.product_name:*watch*","language":"lucene"},"label":"Revenue Watches","value_template":"${{value}}","split_color_mode":"gradient"},{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"rgba(96,146,192,1)","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":"2","point_size":"5","fill":"0","stacked":"none","filter":{"query":"products.product_name:*bag*","language":"lucene"},"label":"Revenue Bags","value_template":"${{value}}","split_color_mode":"gradient"},{"id":"faa2c170-b88d-11e8-a451-f37365e9f268","color":"rgba(202,142,174,1)","split_mode":"everything","metrics":[{"id":"faa2c171-b88d-11e8-a451-f37365e9f268","type":"sum","field":"taxful_total_price"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":"2","point_size":"5","fill":"0","stacked":"none","filter":{"query":"products.product_name:*cocktail dress*","language":"lucene"},"label":"Revenue Cocktail Dresses","value_template":"${{value}}","split_color_mode":"gradient"}],"time_field":"order_date","interval":"12h","use_kibana_indexes":true,"axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"legend_position":"bottom","annotations":[{"fields":"taxful_total_price","template":"Ring the bell! ${{taxful_total_price}}","query_string":{"query":"taxful_total_price:>250","language":"lucene"},"id":"c8c30be0-b88f-11e8-a451-f37365e9f268","color":"rgba(25,77,51,1)","time_field":"order_date","icon":"fa-bell","ignore_global_filters":1,"ignore_panel_filters":1,"index_pattern_ref_name":"metrics_1_index_pattern"}],"tooltip_mode":"show_all","drop_last_bucket":0,"isModelInvalid":false,"index_pattern_ref_name":"metrics_0_index_pattern"}}', uiStateJSON: '{}', description: '', version: 1, @@ -119,51 +35,31 @@ export const getSavedObjects = (): SavedObject[] => [ }, references: [ { - name: 'ref_1_index_pattern', - type: 'index_pattern', id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'metrics_0_index_pattern', + type: 'index-pattern', }, { - name: 'ref_2_index_pattern', - type: 'index_pattern', id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'metrics_1_index_pattern', + type: 'index-pattern', }, ], }, - { - id: '10f1a240-b891-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.totalRevenueTitle', { - defaultMessage: '[eCommerce] Total Revenue', - }), - visState: - '{"title":"[eCommerce] Total Revenue","type":"metric","params":{"addTooltip":true,"addLegend":false,"type":"metric","metric":{"percentageMode":false,"useRanges":false,"colorSchema":"Green to Red","metricColorMode":"None","colorsRange":[{"from":0,"to":10000}],"labels":{"show":false},"invertColors":false,"style":{"bgFill":"#000","bgColor":false,"labelColor":false,"subText":"","fontSize":36}}},"aggs":[{"id":"1","enabled":true,"type":"sum","schema":"metric","params":{"field":"taxful_total_price","customLabel":"Total Revenue"}}]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, { id: 'b80e6540-b891-11e8-a6d9-e546fe2bba5f', type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, + updated_at: '2021-07-14T20:45:27.899Z', + version: '2', + migrationVersion: { + visualization: '7.14.0', + }, attributes: { title: i18n.translate('home.sampleData.ecommerceSpec.soldProductsPerDayTitle', { defaultMessage: '[eCommerce] Sold Products per Day', }), visState: - '{"title":"[eCommerce] Sold Products per Day","type":"metrics","params":{"id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"}],"separate_axis":0,"axis_position":"right","formatter":"number","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day"}],"time_field":"order_date","index_pattern_ref_name":"ref_1_index_pattern","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"time_range_mode":"entire_time_range","gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":10,"gauge_style":"half","filter":"","gauge_max":"300","use_kibana_indexes":true},"aggs":[]}', + '{"title":"[eCommerce] Sold Products per Day","type":"metrics","aggs":[],"params":{"time_range_mode":"entire_time_range","id":"61ca57f0-469d-11e7-af02-69e470af7417","type":"gauge","series":[{"id":"61ca57f1-469d-11e7-af02-69e470af7417","color":"#68BC00","split_mode":"everything","metrics":[{"id":"61ca57f2-469d-11e7-af02-69e470af7417","type":"count"},{"id":"fd1e1b90-e4e3-11eb-8234-cb7bfd534fce","type":"math","variables":[{"id":"00374270-e4e4-11eb-8234-cb7bfd534fce","name":"c","field":"61ca57f2-469d-11e7-af02-69e470af7417"}],"script":"params.c / (params._interval / 1000 / 60 / 60 / 24)"}],"separate_axis":0,"axis_position":"right","formatter":"0.0","chart_type":"line","line_width":1,"point_size":1,"fill":0.5,"stacked":"none","label":"Trxns / day","split_color_mode":"gradient","value_template":""}],"time_field":"order_date","interval":"1d","axis_position":"left","axis_formatter":"number","axis_scale":"normal","show_legend":1,"show_grid":1,"gauge_color_rules":[{"value":150,"id":"6da070c0-b891-11e8-b645-195edeb9de84","gauge":"rgba(104,188,0,1)","operator":"gte"},{"value":150,"id":"9b0cdbc0-b891-11e8-b645-195edeb9de84","gauge":"rgba(244,78,59,1)","operator":"lt"}],"gauge_width":"15","gauge_inner_width":"10","gauge_style":"half","filter":"","gauge_max":"300","use_kibana_indexes":true,"hide_last_value_indicator":true,"tooltip_mode":"show_all","drop_last_bucket":0,"isModelInvalid":false,"index_pattern_ref_name":"metrics_0_index_pattern"}}', uiStateJSON: '{}', description: '', version: 1, @@ -173,79 +69,48 @@ export const getSavedObjects = (): SavedObject[] => [ }, references: [ { - name: 'ref_1_index_pattern', - type: 'index_pattern', id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'metrics_0_index_pattern', + type: 'index-pattern', }, ], }, - { - id: '4b3ec120-b892-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.averageSalesPriceTitle', { - defaultMessage: '[eCommerce] Average Sales Price', - }), - visState: - '{"title":"[eCommerce] Average Sales Price","type":"gauge","params":{"type":"gauge","addTooltip":true,"addLegend":true,"isDisplayWarning":false,"gauge":{"verticalSplit":false,"extendRange":true,"percentageMode":false,"gaugeType":"Circle","gaugeStyle":"Full","backStyle":"Full","orientation":"vertical","colorSchema":"Green to Red","gaugeColorMode":"Labels","colorsRange":[{"from":0,"to":50},{"from":50,"to":75},{"from":75,"to":100}],"invertColors":true,"labels":{"show":true,"color":"black"},"scale":{"show":false,"labels":false,"color":"#333"},"type":"meter","style":{"bgWidth":0.9,"width":0.9,"mask":false,"bgMask":false,"maskBars":50,"bgFill":"#eee","bgColor":false,"subText":"per order","fontSize":60,"labelColor":true},"minAngle":0,"maxAngle":6.283185307179586}},"aggs":[{"id":"1","enabled":true,"type":"avg","schema":"metric","params":{"field":"taxful_total_price","customLabel":"average spend"}}]}', - uiStateJSON: - '{"vis":{"defaultColors":{"0 - 50":"rgb(165,0,38)","50 - 75":"rgb(255,255,190)","75 - 100":"rgb(0,104,55)"}}}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, - { - id: '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.averageSoldQuantityTitle', { - defaultMessage: '[eCommerce] Average Sold Quantity', - }), - visState: - '{"title":"[eCommerce] Average Sold Quantity","type":"gauge","params":{"type":"gauge","addTooltip":true,"addLegend":true,"isDisplayWarning":false,"gauge":{"verticalSplit":false,"extendRange":true,"percentageMode":false,"gaugeType":"Circle","gaugeStyle":"Full","backStyle":"Full","orientation":"vertical","colorSchema":"Green to Red","gaugeColorMode":"Labels","colorsRange":[{"from":0,"to":2},{"from":2,"to":3},{"from":3,"to":4}],"invertColors":true,"labels":{"show":true,"color":"black"},"scale":{"show":false,"labels":false,"color":"#333"},"type":"meter","style":{"bgWidth":0.9,"width":0.9,"mask":false,"bgMask":false,"maskBars":50,"bgFill":"#eee","bgColor":false,"subText":"per order","fontSize":60,"labelColor":true},"minAngle":0,"maxAngle":6.283185307179586}},"aggs":[{"id":"1","enabled":true,"type":"avg","schema":"metric","params":{"field":"total_quantity","customLabel":"average items"}}]}', - uiStateJSON: - '{"vis":{"defaultColors":{"0 - 2":"rgb(165,0,38)","2 - 3":"rgb(255,255,190)","3 - 4":"rgb(0,104,55)"}}}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', - }, - }, - references: [], - }, { id: '3ba638e0-b894-11e8-a6d9-e546fe2bba5f', type: 'search', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, + updated_at: '2021-07-16T20:05:53.880Z', + version: '2', + migrationVersion: { + search: '7.9.3', + }, attributes: { title: i18n.translate('home.sampleData.ecommerceSpec.ordersTitle', { defaultMessage: '[eCommerce] Orders', }), description: '', hits: 0, - columns: ['category', 'sku', 'taxful_total_price', 'total_quantity'], + columns: [ + 'category', + 'taxful_total_price', + 'products.price', + 'products.product_name', + 'products.manufacturer', + 'sku', + ], sort: [['order_date', 'desc']], version: 1, kibanaSavedObjectMeta: { searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","highlightAll":true,"version":true,"query":{"query":"","language":"kuery"},"filter":[]}', + '{"highlightAll":true,"version":true,"query":{"query":"","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', }, }, - references: [], + references: [ + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], }, { id: '9c6f83f0-bb4d-11e8-9c84-77068524bcab', @@ -264,116 +129,184 @@ export const getSavedObjects = (): SavedObject[] => [ version: 1, kibanaSavedObjectMeta: { searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', + '{"version":true,"query":{"query":"","language":"kuery"},"filter":[],"indexRefName":"kibanaSavedObjectMeta.searchSourceJSON.index"}', }, }, - references: [], - }, - { - id: 'b72dd430-bb4d-11e8-9c84-77068524bcab', - type: 'visualization', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, - attributes: { - title: i18n.translate('home.sampleData.ecommerceSpec.topSellingProductsTitle', { - defaultMessage: '[eCommerce] Top Selling Products', - }), - visState: - '{"title":"[eCommerce] Top Selling Products","type":"tagcloud","params":{"scale":"linear","orientation":"single","minFontSize":18,"maxFontSize":72,"showLabel":false,"palette":{"type":"palette","name":"default"}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}},{"id":"2","enabled":true,"type":"terms","schema":"segment","params":{"field":"products.product_name.keyword","size":7,"order":"desc","orderBy":"1","otherBucket":false,"otherBucketLabel":"Other","missingBucket":false,"missingBucketLabel":"Missing"}}]}', - uiStateJSON: '{}', - description: '', - version: 1, - kibanaSavedObjectMeta: { - searchSourceJSON: - '{"index":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","query":{"query":"","language":"kuery"},"filter":[]}', + references: [ + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', }, - }, - references: [], + ], }, { id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', type: 'index-pattern', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', - migrationVersion: {}, + updated_at: '2021-07-16T20:08:12.675Z', + version: '2', + migrationVersion: { + 'index-pattern': '7.11.0', + }, attributes: { title: 'kibana_sample_data_ecommerce', timeFieldName: 'order_date', + fieldAttrs: + '{"products.manufacturer":{"count":1},"products.price":{"count":1},"products.product_name":{"count":1},"total_quantity":{"count":1}}', + fieldFormatMap: + '{"taxful_total_price":{"id":"number","params":{"pattern":"$0,0.[00]"}},"products.price":{"id":"number","params":{"pattern":"$0,0.00"}},"taxless_total_price":{"id":"number","params":{"pattern":"$0,0.00"}},"products.taxless_price":{"id":"number","params":{"pattern":"$0,0.00"}},"products.taxful_price":{"id":"number","params":{"pattern":"$0,0.00"}},"products.min_price":{"id":"number","params":{"pattern":"$0,0.00"}},"products.base_unit_price":{"id":"number","params":{"pattern":"$0,0.00"}},"products.base_price":{"id":"number","params":{"pattern":"$0,0.00"}}}', fields: '[]', - fieldFormatMap: '{"taxful_total_price":{"id":"number","params":{"pattern":"$0,0.[00]"}}}', + runtimeFieldMap: '{}', + typeMeta: '{}', }, references: [], }, { id: '722b74f0-b882-11e8-a6d9-e546fe2bba5f', type: 'dashboard', - updated_at: '2018-10-01T15:13:03.270Z', - version: '1', + updated_at: '2021-07-16T20:43:03.136Z', + version: '2', references: [ { - name: 'panel_0', + id: '45e07720-b890-11e8-a6d9-e546fe2bba5f', + name: '5:panel_5', type: 'visualization', - id: '37cc8650-b882-11e8-a6d9-e546fe2bba5f', }, { - name: 'panel_1', + id: 'b80e6540-b891-11e8-a6d9-e546fe2bba5f', + name: '7:panel_7', type: 'visualization', - id: 'ed8436b0-b88b-11e8-a6d9-e546fe2bba5f', }, { - name: 'panel_2', - type: 'visualization', - id: '09ffee60-b88c-11e8-a6d9-e546fe2bba5f', + id: '3ba638e0-b894-11e8-a6d9-e546fe2bba5f', + name: '10:panel_10', + type: 'search', }, { - name: 'panel_3', - type: 'visualization', - id: '1c389590-b88d-11e8-a6d9-e546fe2bba5f', + id: '9c6f83f0-bb4d-11e8-9c84-77068524bcab', + name: '11:panel_11', + type: 'map', }, { - name: 'panel_4', - type: 'visualization', - id: '45e07720-b890-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'a5914d17-81fe-4f27-b240-23ac529c1499:control_a5914d17-81fe-4f27-b240-23ac529c1499_0_index_pattern', + type: 'index-pattern', }, { - name: 'panel_5', - type: 'visualization', - id: '10f1a240-b891-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'a5914d17-81fe-4f27-b240-23ac529c1499:control_a5914d17-81fe-4f27-b240-23ac529c1499_1_index_pattern', + type: 'index-pattern', }, { - name: 'panel_6', - type: 'visualization', - id: 'b80e6540-b891-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'a5914d17-81fe-4f27-b240-23ac529c1499:control_a5914d17-81fe-4f27-b240-23ac529c1499_2_index_pattern', + type: 'index-pattern', }, { - name: 'panel_7', - type: 'visualization', - id: '4b3ec120-b892-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'c65434d6-fe64-460f-b07a-c7d267c856ff:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', }, { - name: 'panel_8', - type: 'visualization', - id: '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'c65434d6-fe64-460f-b07a-c7d267c856ff:indexpattern-datasource-layer-c7478794-6767-4286-9d65-1c0ecd909dd8', + type: 'index-pattern', }, { - name: 'panel_9', - type: 'search', - id: '3ba638e0-b894-11e8-a6d9-e546fe2bba5f', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '2e6ef14d-7b03-46d4-a6b8-a962ee36a805:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', }, { - name: 'panel_10', - type: 'visualization', - id: '9c6f83f0-bb4d-11e8-9c84-77068524bcab', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '2e6ef14d-7b03-46d4-a6b8-a962ee36a805:indexpattern-datasource-layer-c7478794-6767-4286-9d65-1c0ecd909dd8', + type: 'index-pattern', }, { - name: 'panel_11', - type: 'visualization', - id: 'b72dd430-bb4d-11e8-9c84-77068524bcab', + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '5108a3bc-d1cf-4255-8c95-2df52577b956:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '5108a3bc-d1cf-4255-8c95-2df52577b956:indexpattern-datasource-layer-4fb42a8e-b133-43c8-805c-a38472053938', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '6bc3fa4a-8f1b-436f-afc1-f3516ee531ce:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '6bc3fa4a-8f1b-436f-afc1-f3516ee531ce:indexpattern-datasource-layer-b6093a53-884f-42c2-9fcc-ba56cfb66c53', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '222c1f05-ca21-4e62-a04a-9a059b4534a7:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '222c1f05-ca21-4e62-a04a-9a059b4534a7:indexpattern-datasource-layer-667067a2-7cdf-4f0e-a9fe-eb4f4f1f2f17', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'a885226c-6830-4731-88a0-8c1d1047841e:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'a885226c-6830-4731-88a0-8c1d1047841e:indexpattern-datasource-layer-0731ee8b-31c5-4be9-92d9-69ee760465d7', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '003bdfc7-4d9e-4bd0-b088-3b18f79588d1:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '003bdfc7-4d9e-4bd0-b088-3b18f79588d1:indexpattern-datasource-layer-97c63ea6-9305-4755-97d1-0f26817c6f9a', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: 'b1697063-c817-4847-aa0d-5bed47137b7e:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + 'b1697063-c817-4847-aa0d-5bed47137b7e:indexpattern-datasource-layer-5ed846c2-a70b-4d9c-a244-f254bef763b8', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: '562bb4bd-16b5-4c7e-9dfa-0f24cae6d1ba:indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + name: + '562bb4bd-16b5-4c7e-9dfa-0f24cae6d1ba:indexpattern-datasource-layer-5ed846c2-a70b-4d9c-a244-f254bef763b8', + type: 'index-pattern', }, ], migrationVersion: { - dashboard: '7.0.0', + dashboard: '7.14.0', }, attributes: { title: i18n.translate('home.sampleData.ecommerceSpec.revenueDashboardTitle', { @@ -383,16 +316,16 @@ export const getSavedObjects = (): SavedObject[] => [ description: i18n.translate('home.sampleData.ecommerceSpec.revenueDashboardDescription', { defaultMessage: 'Analyze mock eCommerce orders and revenue', }), - panelsJSON: - '[{"embeddableConfig":{"vis":{"colors":{"Men\'s Accessories":"#82B5D8","Men\'s Clothing":"#F9BA8F","Men\'s Shoes":"#F29191","Women\'s Accessories":"#F4D598","Women\'s Clothing":"#70DBED","Women\'s Shoes":"#B7DBAB"}}},"gridData":{"x":12,"y":18,"w":36,"h":10,"i":"1"},"panelIndex":"1","version":"7.0.0-alpha1","panelRefName":"panel_0"},{"embeddableConfig":{"vis":{"colors":{"FEMALE":"#6ED0E0","MALE":"#447EBC"},"legendOpen":false}},"gridData":{"x":12,"y":7,"w":12,"h":11,"i":"2"},"panelIndex":"2","version":"7.0.0-alpha1","panelRefName":"panel_1"},{"embeddableConfig":{},"gridData":{"x":0,"y":0,"w":18,"h":7,"i":"3"},"panelIndex":"3","version":"7.0.0-alpha1","panelRefName":"panel_2"},{"embeddableConfig":{},"gridData":{"x":18,"y":0,"w":30,"h":7,"i":"4"},"panelIndex":"4","version":"7.0.0-alpha1","panelRefName":"panel_3"},{"embeddableConfig":{},"gridData":{"x":0,"y":28,"w":48,"h":11,"i":"5"},"panelIndex":"5","version":"7.0.0-alpha1","panelRefName":"panel_4"},{"embeddableConfig":{},"gridData":{"x":0,"y":18,"w":12,"h":10,"i":"6"},"panelIndex":"6","version":"7.0.0-alpha1","panelRefName":"panel_5"},{"embeddableConfig":{},"gridData":{"x":0,"y":7,"w":12,"h":11,"i":"7"},"panelIndex":"7","version":"7.0.0-alpha1","panelRefName":"panel_6"},{"embeddableConfig":{"vis":{"colors":{"0 - 50":"#E24D42","50 - 75":"#EAB839","75 - 100":"#7EB26D"},"defaultColors":{"0 - 50":"rgb(165,0,38)","50 - 75":"rgb(255,255,190)","75 - 100":"rgb(0,104,55)"},"legendOpen":false}},"gridData":{"x":24,"y":7,"w":12,"h":11,"i":"8"},"panelIndex":"8","version":"7.0.0-alpha1","panelRefName":"panel_7"},{"embeddableConfig":{"vis":{"colors":{"0 - 2":"#E24D42","2 - 3":"#F2C96D","3 - 4":"#9AC48A"},"defaultColors":{"0 - 2":"rgb(165,0,38)","2 - 3":"rgb(255,255,190)","3 - 4":"rgb(0,104,55)"},"legendOpen":false}},"gridData":{"x":36,"y":7,"w":12,"h":11,"i":"9"},"panelIndex":"9","version":"7.0.0-alpha1","panelRefName":"panel_8"},{"embeddableConfig":{},"gridData":{"x":0,"y":54,"w":48,"h":18,"i":"10"},"panelIndex":"10","version":"7.0.0-alpha1","panelRefName":"panel_9"},{"embeddableConfig":{"mapZoom":2,"mapCenter":[28.304380682962783,-22.148437500000004]},"gridData":{"x":0,"y":39,"w":24,"h":15,"i":"11"},"panelIndex":"11","version":"7.0.0-alpha1","panelRefName":"panel_10"},{"embeddableConfig":{},"gridData":{"x":24,"y":39,"w":24,"h":15,"i":"12"},"panelIndex":"12","version":"7.0.0-alpha1","panelRefName":"panel_11"}]', optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + panelsJSON: + '[{"version":"7.14.0","type":"visualization","gridData":{"x":0,"y":22,"w":24,"h":10,"i":"5"},"panelIndex":"5","embeddableConfig":{"enhancements":{}},"panelRefName":"panel_5"},{"version":"7.14.0","type":"visualization","gridData":{"x":36,"y":15,"w":12,"h":7,"i":"7"},"panelIndex":"7","embeddableConfig":{"enhancements":{}},"panelRefName":"panel_7"},{"version":"7.14.0","type":"search","gridData":{"x":0,"y":55,"w":48,"h":18,"i":"10"},"panelIndex":"10","embeddableConfig":{"enhancements":{}},"panelRefName":"panel_10"},{"version":"7.14.0","type":"map","gridData":{"x":0,"y":32,"w":24,"h":14,"i":"11"},"panelIndex":"11","embeddableConfig":{"isLayerTOCOpen":false,"enhancements":{},"mapCenter":{"lat":45.88578,"lon":-15.07605,"zoom":2.11},"mapBuffer":{"minLon":-90,"minLat":0,"maxLon":45,"maxLat":66.51326},"openTOCDetails":[],"hiddenLayers":[]},"panelRefName":"panel_11"},{"version":"7.14.0","type":"visualization","gridData":{"x":0,"y":0,"w":18,"h":7,"i":"585b11d3-3461-49a7-8f5b-f56521b9dc8b"},"panelIndex":"585b11d3-3461-49a7-8f5b-f56521b9dc8b","embeddableConfig":{"savedVis":{"title":"[eCommerce] Markdown","description":"","type":"markdown","params":{"fontSize":12,"openLinksInNewTab":false,"markdown":"### Sample eCommerce Data\\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html)."},"uiState":{},"data":{"aggs":[],"searchSource":{"query":{"query":"","language":"kuery"},"filter":[]}}},"enhancements":{}}},{"version":"7.14.0","type":"visualization","gridData":{"x":18,"y":0,"w":30,"h":7,"i":"a5914d17-81fe-4f27-b240-23ac529c1499"},"panelIndex":"a5914d17-81fe-4f27-b240-23ac529c1499","embeddableConfig":{"savedVis":{"title":"[eCommerce] Controls","description":"","type":"input_control_vis","params":{"controls":[{"id":"1536977437774","fieldName":"manufacturer.keyword","parent":"","label":"Manufacturer","type":"list","options":{"type":"terms","multiselect":true,"dynamicOptions":true,"size":5,"order":"desc"},"indexPatternRefName":"control_a5914d17-81fe-4f27-b240-23ac529c1499_0_index_pattern"},{"id":"1536977465554","fieldName":"category.keyword","parent":"","label":"Category","type":"list","options":{"type":"terms","multiselect":true,"dynamicOptions":true,"size":5,"order":"desc"},"indexPatternRefName":"control_a5914d17-81fe-4f27-b240-23ac529c1499_1_index_pattern"},{"id":"1536977596163","fieldName":"total_quantity","parent":"","label":"Quantity","type":"range","options":{"decimalPlaces":0,"step":1},"indexPatternRefName":"control_a5914d17-81fe-4f27-b240-23ac529c1499_2_index_pattern"}],"updateFiltersOnChange":false,"useTimeFilter":true,"pinFilters":false},"uiState":{},"data":{"aggs":[],"searchSource":{"query":{"query":"","language":"kuery"},"filter":[]}}},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":0,"y":7,"w":24,"h":8,"i":"c65434d6-fe64-460f-b07a-c7d267c856ff"},"panelIndex":"c65434d6-fe64-460f-b07a-c7d267c856ff","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"c7478794-6767-4286-9d65-1c0ecd909dd8":{"columns":{"8289349e-6d1b-4abf-b164-0208183d2c34":{"label":"order_date","dataType":"date","operationType":"date_histogram","sourceField":"order_date","isBucketed":true,"scale":"interval","params":{"interval":"1d"}},"041db33b-5c9c-47f3-a5d3-ef5e255d1663X0":{"label":"Part of Weekly revenue","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","customLabel":true},"041db33b-5c9c-47f3-a5d3-ef5e255d1663X1":{"label":"Part of Weekly revenue","dataType":"number","operationType":"math","isBucketed":false,"scale":"ratio","params":{"tinymathAst":{"type":"function","name":"subtract","args":[{"type":"function","name":"divide","args":["041db33b-5c9c-47f3-a5d3-ef5e255d1663X0",10000],"location":{"min":0,"max":32},"text":"sum(taxful_total_price) / 10000 "},1],"location":{"min":0,"max":35},"text":"sum(taxful_total_price) / 10000 - 1"}},"references":["041db33b-5c9c-47f3-a5d3-ef5e255d1663X0"],"customLabel":true},"041db33b-5c9c-47f3-a5d3-ef5e255d1663":{"label":"% of target ($10k)","dataType":"number","operationType":"formula","isBucketed":false,"scale":"ratio","params":{"formula":"sum(taxful_total_price) / 10000 - 1","isFormulaBroken":false,"format":{"id":"percent","params":{"decimals":0}}},"references":["041db33b-5c9c-47f3-a5d3-ef5e255d1663X1"],"customLabel":true}},"columnOrder":["8289349e-6d1b-4abf-b164-0208183d2c34","041db33b-5c9c-47f3-a5d3-ef5e255d1663","041db33b-5c9c-47f3-a5d3-ef5e255d1663X0","041db33b-5c9c-47f3-a5d3-ef5e255d1663X1"],"incompleteColumns":{}}}}},"visualization":{"legend":{"isVisible":true,"position":"right"},"valueLabels":"hide","fittingFunction":"None","yLeftExtent":{"mode":"full"},"yRightExtent":{"mode":"full"},"axisTitlesVisibilitySettings":{"x":false,"yLeft":false,"yRight":true},"tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"preferredSeriesType":"bar_stacked","layers":[{"layerId":"c7478794-6767-4286-9d65-1c0ecd909dd8","seriesType":"bar_stacked","xAccessor":"8289349e-6d1b-4abf-b164-0208183d2c34","accessors":["041db33b-5c9c-47f3-a5d3-ef5e255d1663"]}]},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-c7478794-6767-4286-9d65-1c0ecd909dd8"}]},"enhancements":{},"hidePanelTitles":false},"title":"% of target revenue ($10k)"},{"version":"7.14.0","type":"lens","gridData":{"x":24,"y":7,"w":12,"h":8,"i":"2e6ef14d-7b03-46d4-a6b8-a962ee36a805"},"panelIndex":"2e6ef14d-7b03-46d4-a6b8-a962ee36a805","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsMetric","state":{"datasourceStates":{"indexpattern":{"layers":{"c7478794-6767-4286-9d65-1c0ecd909dd8":{"columns":{"041db33b-5c9c-47f3-a5d3-ef5e255d1663":{"label":"Sum of revenue","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","customLabel":true}},"columnOrder":["041db33b-5c9c-47f3-a5d3-ef5e255d1663"],"incompleteColumns":{}}}}},"visualization":{"layerId":"c7478794-6767-4286-9d65-1c0ecd909dd8","accessor":"041db33b-5c9c-47f3-a5d3-ef5e255d1663"},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-c7478794-6767-4286-9d65-1c0ecd909dd8"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":36,"y":7,"w":12,"h":8,"i":"5108a3bc-d1cf-4255-8c95-2df52577b956"},"panelIndex":"5108a3bc-d1cf-4255-8c95-2df52577b956","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsMetric","state":{"datasourceStates":{"indexpattern":{"layers":{"4fb42a8e-b133-43c8-805c-a38472053938":{"columns":{"020bbfdf-9ef8-4802-aa9e-342d2ea0bebf":{"label":"Median spending","dataType":"number","operationType":"median","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","customLabel":true}},"columnOrder":["020bbfdf-9ef8-4802-aa9e-342d2ea0bebf"],"incompleteColumns":{}}}}},"visualization":{"layerId":"4fb42a8e-b133-43c8-805c-a38472053938","accessor":"020bbfdf-9ef8-4802-aa9e-342d2ea0bebf"},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-4fb42a8e-b133-43c8-805c-a38472053938"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":0,"y":15,"w":24,"h":7,"i":"6bc3fa4a-8f1b-436f-afc1-f3516ee531ce"},"panelIndex":"6bc3fa4a-8f1b-436f-afc1-f3516ee531ce","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"b6093a53-884f-42c2-9fcc-ba56cfb66c53":{"columns":{"15c45f89-a149-443a-a830-aa8c3a9317db":{"label":"order_date","dataType":"date","operationType":"date_histogram","sourceField":"order_date","isBucketed":true,"scale":"interval","params":{"interval":"1d"}},"2b41b3d8-2f62-407a-a866-960f254c679d":{"label":"Total items","dataType":"number","operationType":"sum","sourceField":"products.quantity","isBucketed":false,"scale":"ratio","customLabel":true},"ddc92e50-4d5c-413e-b91b-3e504889fa65":{"label":"Transactions","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"Records","customLabel":true},"eadae280-2da3-4d1d-a0e1-f9733f89c15b":{"label":"Last week","dataType":"number","operationType":"sum","sourceField":"products.quantity","isBucketed":false,"scale":"ratio","timeShift":"1w","customLabel":true},"5e31e5d3-2aaa-4475-a130-3b69bf2f748a":{"label":"Tx. last week","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"Records","timeShift":"1w","customLabel":true}},"columnOrder":["15c45f89-a149-443a-a830-aa8c3a9317db","2b41b3d8-2f62-407a-a866-960f254c679d","eadae280-2da3-4d1d-a0e1-f9733f89c15b","ddc92e50-4d5c-413e-b91b-3e504889fa65","5e31e5d3-2aaa-4475-a130-3b69bf2f748a"],"incompleteColumns":{}}}}},"visualization":{"legend":{"isVisible":true,"position":"right"},"valueLabels":"hide","fittingFunction":"None","yLeftExtent":{"mode":"full"},"yRightExtent":{"mode":"full"},"axisTitlesVisibilitySettings":{"x":false,"yLeft":false,"yRight":true},"tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"preferredSeriesType":"line","layers":[{"layerId":"b6093a53-884f-42c2-9fcc-ba56cfb66c53","accessors":["2b41b3d8-2f62-407a-a866-960f254c679d","eadae280-2da3-4d1d-a0e1-f9733f89c15b","5e31e5d3-2aaa-4475-a130-3b69bf2f748a","ddc92e50-4d5c-413e-b91b-3e504889fa65"],"position":"top","seriesType":"line","showGridlines":false,"xAccessor":"15c45f89-a149-443a-a830-aa8c3a9317db","yConfig":[{"forAccessor":"eadae280-2da3-4d1d-a0e1-f9733f89c15b","color":"#b6e0d5"},{"forAccessor":"5e31e5d3-2aaa-4475-a130-3b69bf2f748a","color":"#edafc4"}]}],"curveType":"LINEAR"},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-b6093a53-884f-42c2-9fcc-ba56cfb66c53"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":24,"y":15,"w":12,"h":7,"i":"222c1f05-ca21-4e62-a04a-9a059b4534a7"},"panelIndex":"222c1f05-ca21-4e62-a04a-9a059b4534a7","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsMetric","state":{"datasourceStates":{"indexpattern":{"layers":{"667067a2-7cdf-4f0e-a9fe-eb4f4f1f2f17":{"columns":{"c52c2003-ae58-4604-bae7-52ba0fb38a01":{"label":"Avg. items sold","dataType":"number","operationType":"average","sourceField":"total_quantity","isBucketed":false,"scale":"ratio","params":{"format":{"id":"number","params":{"decimals":1}}},"customLabel":true}},"columnOrder":["c52c2003-ae58-4604-bae7-52ba0fb38a01"],"incompleteColumns":{}}}}},"visualization":{"layerId":"667067a2-7cdf-4f0e-a9fe-eb4f4f1f2f17","accessor":"c52c2003-ae58-4604-bae7-52ba0fb38a01"},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-667067a2-7cdf-4f0e-a9fe-eb4f4f1f2f17"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":24,"y":22,"w":24,"h":10,"i":"003bdfc7-4d9e-4bd0-b088-3b18f79588d1"},"panelIndex":"003bdfc7-4d9e-4bd0-b088-3b18f79588d1","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"97c63ea6-9305-4755-97d1-0f26817c6f9a":{"columns":{"9f61a7df-198e-4754-b34c-81ed544136ba":{"label":"Top values of category.keyword","dataType":"string","operationType":"terms","scale":"ordinal","sourceField":"category.keyword","isBucketed":true,"params":{"size":10,"orderBy":{"type":"column","columnId":"5575214b-7f21-4b6c-8bc1-34433c6a0c58"},"orderDirection":"desc","otherBucket":true,"missingBucket":false}},"ebcb19af-0900-4439-949f-d8cd9bccde19":{"label":"order_date","dataType":"date","operationType":"date_histogram","sourceField":"order_date","isBucketed":true,"scale":"interval","params":{"interval":"1d"}},"5575214b-7f21-4b6c-8bc1-34433c6a0c58":{"label":"Count of records","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"Records"}},"columnOrder":["9f61a7df-198e-4754-b34c-81ed544136ba","ebcb19af-0900-4439-949f-d8cd9bccde19","5575214b-7f21-4b6c-8bc1-34433c6a0c58"],"incompleteColumns":{}}}}},"visualization":{"legend":{"isVisible":true,"position":"right"},"valueLabels":"inside","fittingFunction":"None","yLeftExtent":{"mode":"full"},"yRightExtent":{"mode":"full"},"axisTitlesVisibilitySettings":{"x":false,"yLeft":false,"yRight":true},"tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"preferredSeriesType":"bar_percentage_stacked","layers":[{"layerId":"97c63ea6-9305-4755-97d1-0f26817c6f9a","accessors":["5575214b-7f21-4b6c-8bc1-34433c6a0c58"],"position":"top","seriesType":"bar_percentage_stacked","showGridlines":false,"xAccessor":"ebcb19af-0900-4439-949f-d8cd9bccde19","splitAccessor":"9f61a7df-198e-4754-b34c-81ed544136ba"}]},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-97c63ea6-9305-4755-97d1-0f26817c6f9a"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":24,"y":32,"w":24,"h":14,"i":"a885226c-6830-4731-88a0-8c1d1047841e"},"panelIndex":"a885226c-6830-4731-88a0-8c1d1047841e","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsDatatable","state":{"datasourceStates":{"indexpattern":{"layers":{"0731ee8b-31c5-4be9-92d9-69ee760465d7":{"columns":{"7bf8f089-1542-40bd-b349-45fdfc309ac6":{"label":"order_date","dataType":"date","operationType":"date_histogram","sourceField":"order_date","isBucketed":true,"scale":"interval","params":{"interval":"1d"}},"826b2f39-b616-40b2-a222-972fdc1d7596":{"label":"This week","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","customLabel":true},"cfd45c47-fc41-430c-9e7a-b71dc0c916b0":{"label":"1 week ago","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","timeShift":"1w","customLabel":true},"bf51c1af-443e-49f4-a21f-54c87bfc5677X0":{"label":"Part of Difference","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","customLabel":true},"bf51c1af-443e-49f4-a21f-54c87bfc5677X1":{"label":"Part of Difference","dataType":"number","operationType":"sum","sourceField":"taxful_total_price","isBucketed":false,"scale":"ratio","timeShift":"1w","customLabel":true},"bf51c1af-443e-49f4-a21f-54c87bfc5677X2":{"label":"Part of Difference","dataType":"number","operationType":"math","isBucketed":false,"scale":"ratio","params":{"tinymathAst":{"type":"function","name":"subtract","args":["bf51c1af-443e-49f4-a21f-54c87bfc5677X0","bf51c1af-443e-49f4-a21f-54c87bfc5677X1"],"location":{"min":0,"max":61},"text":"sum(taxful_total_price) - sum(taxful_total_price, shift=\'1w\')"}},"references":["bf51c1af-443e-49f4-a21f-54c87bfc5677X0","bf51c1af-443e-49f4-a21f-54c87bfc5677X1"],"customLabel":true},"bf51c1af-443e-49f4-a21f-54c87bfc5677":{"label":"Difference","dataType":"number","operationType":"formula","isBucketed":false,"scale":"ratio","params":{"formula":"sum(taxful_total_price) - sum(taxful_total_price, shift=\'1w\')","isFormulaBroken":false,"format":{"id":"number","params":{"decimals":2}}},"references":["bf51c1af-443e-49f4-a21f-54c87bfc5677X2"],"customLabel":true}},"columnOrder":["7bf8f089-1542-40bd-b349-45fdfc309ac6","826b2f39-b616-40b2-a222-972fdc1d7596","cfd45c47-fc41-430c-9e7a-b71dc0c916b0","bf51c1af-443e-49f4-a21f-54c87bfc5677","bf51c1af-443e-49f4-a21f-54c87bfc5677X0","bf51c1af-443e-49f4-a21f-54c87bfc5677X1","bf51c1af-443e-49f4-a21f-54c87bfc5677X2"],"incompleteColumns":{}}}}},"visualization":{"layerId":"0731ee8b-31c5-4be9-92d9-69ee760465d7","columns":[{"columnId":"7bf8f089-1542-40bd-b349-45fdfc309ac6"},{"columnId":"826b2f39-b616-40b2-a222-972fdc1d7596","alignment":"left"},{"columnId":"cfd45c47-fc41-430c-9e7a-b71dc0c916b0"},{"columnId":"bf51c1af-443e-49f4-a21f-54c87bfc5677","isTransposed":false,"colorMode":"text","palette":{"name":"custom","type":"palette","params":{"steps":5,"stops":[{"color":"#D36086","stop":0},{"color":"#209280","stop":2249.03125}],"continuity":"above","rangeType":"number","colorStops":[{"color":"#D36086","stop":-10000},{"color":"#209280","stop":0}],"rangeMin":-10000,"rangeMax":0,"name":"custom"}}}]},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-0731ee8b-31c5-4be9-92d9-69ee760465d7"}]},"enhancements":{}}},{"version":"7.14.0","type":"lens","gridData":{"x":24,"y":46,"w":24,"h":9,"i":"562bb4bd-16b5-4c7e-9dfa-0f24cae6d1ba"},"panelIndex":"562bb4bd-16b5-4c7e-9dfa-0f24cae6d1ba","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"5ed846c2-a70b-4d9c-a244-f254bef763b8":{"columns":{"d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46":{"label":"Product name","dataType":"string","operationType":"terms","scale":"ordinal","sourceField":"products.product_name.keyword","isBucketed":true,"params":{"size":5,"orderBy":{"type":"column","columnId":"7ac31901-277a-46e2-8128-8d684b2c1127"},"orderDirection":"desc","otherBucket":false,"missingBucket":false},"customLabel":true},"7ac31901-277a-46e2-8128-8d684b2c1127":{"label":"Items","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"Records","customLabel":true}},"columnOrder":["d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46","7ac31901-277a-46e2-8128-8d684b2c1127"],"incompleteColumns":{}}}}},"visualization":{"legend":{"isVisible":true,"position":"right"},"valueLabels":"inside","fittingFunction":"None","yLeftExtent":{"mode":"full"},"yRightExtent":{"mode":"full"},"axisTitlesVisibilitySettings":{"x":false,"yLeft":true,"yRight":true},"tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"preferredSeriesType":"bar_horizontal","layers":[{"layerId":"5ed846c2-a70b-4d9c-a244-f254bef763b8","accessors":["7ac31901-277a-46e2-8128-8d684b2c1127"],"position":"top","seriesType":"bar_horizontal","showGridlines":false,"xAccessor":"d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46"}]},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-5ed846c2-a70b-4d9c-a244-f254bef763b8"}]},"timeRange":{"from":"now-2w","to":"now-1w"},"hidePanelTitles":false,"enhancements":{}},"title":"Top products last week"},{"version":"7.14.0","type":"lens","gridData":{"x":0,"y":46,"w":24,"h":9,"i":"b1697063-c817-4847-aa0d-5bed47137b7e"},"panelIndex":"b1697063-c817-4847-aa0d-5bed47137b7e","embeddableConfig":{"attributes":{"title":"","type":"lens","visualizationType":"lnsXY","state":{"datasourceStates":{"indexpattern":{"layers":{"5ed846c2-a70b-4d9c-a244-f254bef763b8":{"columns":{"d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46":{"label":"Product name","dataType":"string","operationType":"terms","scale":"ordinal","sourceField":"products.product_name.keyword","isBucketed":true,"params":{"size":5,"orderBy":{"type":"column","columnId":"7ac31901-277a-46e2-8128-8d684b2c1127"},"orderDirection":"desc","otherBucket":false,"missingBucket":false},"customLabel":true},"7ac31901-277a-46e2-8128-8d684b2c1127":{"label":"Items","dataType":"number","operationType":"count","isBucketed":false,"scale":"ratio","sourceField":"Records","customLabel":true}},"columnOrder":["d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46","7ac31901-277a-46e2-8128-8d684b2c1127"],"incompleteColumns":{}}}}},"visualization":{"legend":{"isVisible":true,"position":"right"},"valueLabels":"inside","fittingFunction":"None","yLeftExtent":{"mode":"full"},"yRightExtent":{"mode":"full"},"axisTitlesVisibilitySettings":{"x":false,"yLeft":true,"yRight":true},"tickLabelsVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"gridlinesVisibilitySettings":{"x":true,"yLeft":true,"yRight":true},"preferredSeriesType":"bar_horizontal","layers":[{"layerId":"5ed846c2-a70b-4d9c-a244-f254bef763b8","accessors":["7ac31901-277a-46e2-8128-8d684b2c1127"],"position":"top","seriesType":"bar_horizontal","showGridlines":false,"xAccessor":"d77cdd24-dedc-48dd-9a4b-d34c6f1a6c46"}]},"query":{"query":"","language":"kuery"},"filters":[]},"references":[{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-current-indexpattern"},{"type":"index-pattern","id":"ff959d40-b880-11e8-a6d9-e546fe2bba5f","name":"indexpattern-datasource-layer-5ed846c2-a70b-4d9c-a244-f254bef763b8"}]},"hidePanelTitles":false,"enhancements":{}},"title":"Top products this week"}]', version: 1, timeRestore: true, timeTo: 'now', timeFrom: 'now-7d', refreshInterval: { - pause: false, - value: 900000, + pause: true, + value: 0, }, kibanaSavedObjectMeta: { searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts index a50e7e2e22a9c4..9f0bf34eaebb6f 100644 --- a/src/plugins/kibana_utils/server/report_server_error.ts +++ b/src/plugins/kibana_utils/server/report_server_error.ts @@ -24,6 +24,7 @@ export class KbnServerError extends KbnError { * @returns `KbnServerError` */ export function getKbnServerError(e: Error) { + if (e instanceof KbnServerError) return e; return new KbnServerError( e.message ?? 'Unknown error', e instanceof ResponseError ? e.statusCode : 500, diff --git a/src/plugins/presentation_util/public/components/index.tsx b/src/plugins/presentation_util/public/components/index.tsx index 508a1f49830314..007fe05ddc4851 100644 --- a/src/plugins/presentation_util/public/components/index.tsx +++ b/src/plugins/presentation_util/public/components/index.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { Suspense, ComponentType, ReactElement } from 'react'; +import React, { Suspense, ComponentType, ReactElement, Ref } from 'react'; import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; /** @@ -14,16 +14,19 @@ import { EuiLoadingSpinner, EuiErrorBoundary } from '@elastic/eui'; * @param Component A component deferred by `React.lazy` * @param fallback A fallback component to render while things load; default is `EuiLoadingSpinner` */ -export const withSuspense =

( +export const withSuspense =

( Component: ComponentType

, fallback: ReactElement | null = -) => (props: P) => ( - - - - - -); +) => + React.forwardRef((props: P, ref: Ref) => { + return ( + + + + + + ); + }); export const LazyLabsBeakerButton = React.lazy(() => import('./labs/labs_beaker_button')); @@ -34,3 +37,5 @@ export const LazyDashboardPicker = React.lazy(() => import('./dashboard_picker') export const LazySavedObjectSaveModalDashboard = React.lazy( () => import('./saved_object_save_modal_dashboard') ); + +export * from './types'; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index 1e26011ff58ae6..f771a73c1df2b3 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -38,6 +38,8 @@ export { withSuspense, } from './components'; +export * from './components/types'; + export { AddFromLibraryButton, PrimaryActionButton, diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js index c380b0e09e7d32..90a5633926c88b 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js @@ -106,6 +106,7 @@ export const FilterRatioAgg = (props) => { query={model.numerator} onChange={handleNumeratorQueryChange} indexPatterns={[indexPattern]} + data-test-subj="filterRatioNumeratorInput" /> @@ -124,6 +125,7 @@ export const FilterRatioAgg = (props) => { query={model.denominator} onChange={handleDenominatorQueryChange} indexPatterns={[indexPattern]} + data-test-subj="filterRatioDenominatorInput" /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js index 7d18af2bd0d59c..efb51bc8350a29 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js +++ b/src/plugins/vis_type_timeseries/public/application/components/index_pattern.js @@ -323,7 +323,7 @@ export const IndexPattern = ({ content={ & { indexPatterns: IndexPatternValue[]; + 'data-test-subj'?: string; }; -export function QueryBarWrapper({ query, onChange, indexPatterns }: QueryBarWrapperProps) { +export function QueryBarWrapper({ + query, + onChange, + indexPatterns, + 'data-test-subj': dataTestSubj, +}: QueryBarWrapperProps) { const { indexPatterns: indexPatternsService } = getDataStart(); const [indexes, setIndexes] = useState([]); @@ -58,6 +64,7 @@ export function QueryBarWrapper({ query, onChange, indexPatterns }: QueryBarWrap onChange={onChange} indexPatterns={indexes} {...coreStartContext} + dataTestSubj={dataTestSubj} /> ); } diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js index 000701c3a0764a..723a054baeeae4 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge.js @@ -108,10 +108,15 @@ export class Gauge extends Component { ref={(el) => (this.inner = el)} style={styles.inner} > -

+
{title}
-
+
{formatter(value)}
{additionalLabel} @@ -124,10 +129,15 @@ export class Gauge extends Component { ref={(el) => (this.inner = el)} style={styles.inner} > -
+
{formatter(value)}
-
+
{title}
{additionalLabel} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js index 30b7844a90fdac..165f5080af93a4 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/gauge_vis.js @@ -131,15 +131,19 @@ export class GaugeVis extends Component { if (type === 'half') { svg = ( - - + + ); } else { svg = ( - - + + ); } diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index 9d6381f21b11f6..72b2c7ce34fd88 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -135,7 +135,7 @@ export class TopN extends Component {
-
+
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.ts index d8bce87bb58c13..b90ae765196d78 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.test.ts @@ -16,7 +16,7 @@ describe('getBucketSize', () => { body: { timerange: { min: '2017-01-01T00:00:00.000Z', - max: '2017-01-01T01:00:00.000Z', + max: '2017-07-01T01:00:00.000Z', }, }, } as VisTypeTimeseriesVisDataRequest; @@ -29,9 +29,10 @@ describe('getBucketSize', () => { test('returns auto calculated buckets', () => { const result = getBucketSize(req, 'auto', capabilities, 100); + const expectedValue = 86400; // 24h - expect(result).toHaveProperty('bucketSize', 30); - expect(result).toHaveProperty('intervalString', '30s'); + expect(result).toHaveProperty('bucketSize', expectedValue); + expect(result).toHaveProperty('intervalString', `${expectedValue}s`); }); test('returns overridden buckets (1s)', () => { @@ -56,16 +57,23 @@ describe('getBucketSize', () => { }); test('returns overridden buckets (>=2d)', () => { - const result = getBucketSize(req, '>=2d', capabilities, 100); + const result = getBucketSize(req, '>=2d', capabilities, 1000); expect(result).toHaveProperty('bucketSize', 86400 * 2); expect(result).toHaveProperty('intervalString', '2d'); }); - test('returns overridden buckets (>=10s)', () => { - const result = getBucketSize(req, '>=10s', capabilities, 100); + test('returns overridden buckets (>=5d)', () => { + const result = getBucketSize(req, '>=5d', capabilities, 100); - expect(result).toHaveProperty('bucketSize', 30); - expect(result).toHaveProperty('intervalString', '30s'); + expect(result).toHaveProperty('bucketSize', 432000); + expect(result).toHaveProperty('intervalString', '5d'); + }); + + test('returns overridden buckets for 1 bar and >=1d interval', () => { + const result = getBucketSize(req, '>=1d', capabilities, 1); + + expect(result).toHaveProperty('bucketSize', 2592000); + expect(result).toHaveProperty('intervalString', '2592000s'); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.ts index d7dc730b812c3b..7f5874c0763f5d 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_bucket_size.ts @@ -20,9 +20,8 @@ import type { SearchCapabilities } from '../../search_strategies'; import type { VisTypeTimeseriesVisDataRequest } from '../../../types'; const calculateBucketData = (timeInterval: string, capabilities: SearchCapabilities) => { - let intervalString = capabilities - ? capabilities.getValidTimeInterval(timeInterval) - : timeInterval; + let intervalString = capabilities?.getValidTimeInterval(timeInterval) ?? timeInterval; + const intervalStringMatch = intervalString.match(INTERVAL_STRING_RE); const parsedInterval = parseInterval(intervalString); @@ -34,10 +33,6 @@ const calculateBucketData = (timeInterval: string, capabilities: SearchCapabilit bucketSize = 1; } - if (bucketSize > capabilities.maxBucketsLimit) { - bucketSize = capabilities.maxBucketsLimit; - } - // Check decimal if (parsedInterval && parsedInterval.value % 1 !== 0) { if (parsedInterval.unit !== 'ms') { @@ -60,10 +55,7 @@ const calculateBucketData = (timeInterval: string, capabilities: SearchCapabilit }; }; -const calculateBucketSizeForAutoInterval = ( - req: VisTypeTimeseriesVisDataRequest, - maxBars: number -) => { +const calcAutoInterval = (req: VisTypeTimeseriesVisDataRequest, maxBars: number) => { const { from, to } = getTimerange(req); const timerange = to.valueOf() - from.valueOf(); @@ -76,24 +68,24 @@ export const getBucketSize = ( capabilities: SearchCapabilities, bars: number ) => { - const defaultBucketSize = calculateBucketSizeForAutoInterval(req, bars); - let intervalString = `${defaultBucketSize}s`; + const userIntervalMatches = Boolean(interval) && interval.match(INTERVAL_STRING_RE); + + if (userIntervalMatches) { + return calculateBucketData(interval, capabilities); + } const gteAutoMatch = Boolean(interval) && interval.match(GTE_INTERVAL_RE); + const autoInterval = calcAutoInterval(req, bars); + const autoBucketData = calculateBucketData(`${autoInterval}s`, capabilities); if (gteAutoMatch) { - const bucketData = calculateBucketData(gteAutoMatch[1], capabilities); + const gteBucketData = calculateBucketData(gteAutoMatch[1], capabilities); + const gteInSecondInterval = convertIntervalToUnit(gteBucketData.intervalString, 's'); - if (bucketData.bucketSize >= defaultBucketSize) { - return bucketData; + if (gteInSecondInterval && gteInSecondInterval?.value > autoInterval) { + return gteBucketData; } } - const matches = interval && interval.match(INTERVAL_STRING_RE); - - if (matches) { - intervalString = interval; - } - - return calculateBucketData(intervalString, capabilities); + return autoBucketData; }; diff --git a/src/plugins/vis_type_xy/public/components/xy_settings.tsx b/src/plugins/vis_type_xy/public/components/xy_settings.tsx index 8d6a7eecdfe522..6d1425f488a47a 100644 --- a/src/plugins/vis_type_xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_type_xy/public/components/xy_settings.tsx @@ -60,23 +60,15 @@ type XYSettingsProps = Pick< legendPosition: Position; }; -function getValueLabelsStyling(isHorizontal: boolean) { - const VALUE_LABELS_MAX_FONTSIZE = 15; +function getValueLabelsStyling() { + const VALUE_LABELS_MAX_FONTSIZE = 12; const VALUE_LABELS_MIN_FONTSIZE = 10; - const VALUE_LABELS_VERTICAL_OFFSET = -10; - const VALUE_LABELS_HORIZONTAL_OFFSET = 10; return { displayValue: { fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE }, - fill: { textInverted: true, textBorder: 2 }, - alignment: isHorizontal - ? { - vertical: VerticalAlignment.Middle, - } - : { horizontal: HorizontalAlignment.Center }, - offsetX: isHorizontal ? VALUE_LABELS_HORIZONTAL_OFFSET : 0, - offsetY: isHorizontal ? 0 : VALUE_LABELS_VERTICAL_OFFSET, + fill: { textInverted: false, textContrast: true }, + alignment: { horizontal: HorizontalAlignment.Center, vertical: VerticalAlignment.Middle }, }, }; } @@ -103,7 +95,7 @@ export const XYSettings: FC = ({ const theme = themeService.useChartsTheme(); const baseTheme = themeService.useChartsBaseTheme(); const dimmingOpacity = getUISettings().get('visualization:dimmingOpacity'); - const valueLabelsStyling = getValueLabelsStyling(rotation === 90 || rotation === -90); + const valueLabelsStyling = getValueLabelsStyling(); const themeOverrides: PartialTheme = { markSizeRatio, diff --git a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx index ebf36944959819..d186617fef2ae5 100644 --- a/src/plugins/vis_type_xy/public/utils/render_all_series.tsx +++ b/src/plugins/vis_type_xy/public/utils/render_all_series.tsx @@ -131,7 +131,12 @@ export const renderAllSeries = ( minBarHeight={2} displayValueSettings={{ showValueLabel, - overflowConstraints: [LabelOverflowConstraint.ChartEdges], + isValueContainedInElement: false, + isAlternatingValueLabel: false, + overflowConstraints: [ + LabelOverflowConstraint.ChartEdges, + LabelOverflowConstraint.BarGeometry, + ], }} /> ); diff --git a/src/plugins/visualize/public/application/components/deprecation_vis_warning.tsx b/src/plugins/visualize/public/application/components/deprecation_vis_warning.tsx new file mode 100644 index 00000000000000..6389f52996926f --- /dev/null +++ b/src/plugins/visualize/public/application/components/deprecation_vis_warning.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { useKibana } from '../../../../kibana_react/public'; +import { VisualizeServices } from '../types'; + +export const LEGACY_CHARTS_LIBRARY = 'visualization:visualize:legacyChartsLibrary'; + +export const DeprecationWarning = () => { + const { services } = useKibana(); + const canEditAdvancedSettings = services.application.capabilities.advancedSettings.save; + const advancedSettingsLink = services.application.getUrlForApp('management', { + path: `/kibana/settings?query=${LEGACY_CHARTS_LIBRARY}`, + }); + + return ( + + {canEditAdvancedSettings && ( + + + + ), + }} + /> + )} + {!canEditAdvancedSettings && ( + + )} + + ), + }} + /> + } + iconType="alert" + color="warning" + size="s" + /> + ); +}; diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index a03073e61f59cc..22f635460c353f 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -13,12 +13,14 @@ import { EuiScreenReaderOnly } from '@elastic/eui'; import { AppMountParameters } from 'kibana/public'; import { VisualizeTopNav } from './visualize_top_nav'; import { ExperimentalVisInfo } from './experimental_vis_info'; +import { DeprecationWarning, LEGACY_CHARTS_LIBRARY } from './deprecation_vis_warning'; import { SavedVisInstance, VisualizeAppState, VisualizeAppStateContainer, VisualizeEditorVisInstance, } from '../types'; +import { getUISettings } from '../../services'; interface VisualizeEditorCommonProps { visInstance?: VisualizeEditorVisInstance; @@ -37,6 +39,13 @@ interface VisualizeEditorCommonProps { embeddableId?: string; } +const isXYAxis = (visType: string | undefined): boolean => { + if (!visType) { + return false; + } + return ['area', 'line', 'histogram', 'horizontal_bar', 'point_series'].includes(visType); +}; + export const VisualizeEditorCommon = ({ visInstance, appState, @@ -53,6 +62,7 @@ export const VisualizeEditorCommon = ({ embeddableId, visEditorRef, }: VisualizeEditorCommonProps) => { + const hasXYLegacyChartsEnabled = getUISettings().get(LEGACY_CHARTS_LIBRARY); return (
{visInstance && appState && currentAppState && ( @@ -73,6 +83,9 @@ export const VisualizeEditorCommon = ({ /> )} {visInstance?.vis?.type?.stage === 'experimental' && } + {/* Adds a deprecation warning for vislib xy axis charts */} + {/* Should be removed when this issue is closed https://github.com/elastic/kibana/issues/103209 */} + {isXYAxis(visInstance?.vis.type.name) && hasXYLegacyChartsEnabled && } {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {visInstance && ( diff --git a/src/plugins/visualize/public/services.ts b/src/plugins/visualize/public/services.ts index 97ff7923379b72..8b8f98e9a15ed0 100644 --- a/src/plugins/visualize/public/services.ts +++ b/src/plugins/visualize/public/services.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { createGetterSetter } from '../../../plugins/kibana_utils/public'; import type { IUiSettingsClient } from '../../../core/public'; diff --git a/test/api_integration/apis/suggestions/suggestions.js b/test/api_integration/apis/suggestions/suggestions.js index 292e3f599d81a1..ca43641d881cdf 100644 --- a/test/api_integration/apis/suggestions/suggestions.js +++ b/test/api_integration/apis/suggestions/suggestions.js @@ -44,5 +44,14 @@ export default function ({ getService }) { query: 'nes', }) .expect(200, ['nestedValue'])); + + it('should return 404 if index is not found', () => + supertest + .post('/api/kibana/suggestions/values/not_found') + .send({ + field: 'baz.keyword', + query: '1', + }) + .expect(404)); }); } diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index adb99d0d42d02b..23e81fb7b2d2f3 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const toTime = `${todayYearMonthDay} @ 23:59:59.999`; await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.be(12); + expect(panelCount).to.be(15); }); }); diff --git a/test/functional/apps/visualize/_area_chart.ts b/test/functional/apps/visualize/_area_chart.ts index be777607c78361..e88754823f6cb2 100644 --- a/test/functional/apps/visualize/_area_chart.ts +++ b/test/functional/apps/visualize/_area_chart.ts @@ -94,6 +94,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForVisualization(); }); + // Should be removed when this issue is closed https://github.com/elastic/kibana/issues/103209 + it('should show/hide a deprecation warning depending on the library selected', async () => { + await PageObjects.visualize.getDeprecationWarningStatus(); + }); + it('should have inspector enabled', async function () { await inspector.expectIsEnabled(); }); diff --git a/test/functional/apps/visualize/_line_chart_split_series.ts b/test/functional/apps/visualize/_line_chart_split_series.ts index 1c4b34b855cdea..91d44a6fc40da4 100644 --- a/test/functional/apps/visualize/_line_chart_split_series.ts +++ b/test/functional/apps/visualize/_line_chart_split_series.ts @@ -207,19 +207,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); const labels = await PageObjects.visChart.getYAxisLabels(); const expectedLabels = await PageObjects.visChart.getExpectedValue( - [ - '0', - '1,000', - '2,000', - '3,000', - '4,000', - '5,000', - '6,000', - '7,000', - '8,000', - '9,000', - '10,000', - ], + ['0', '2,000', '4,000', '6,000', '8,000', '10,000'], ['0', '1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'] ); expect(labels).to.eql(expectedLabels); @@ -230,7 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); const labels = await PageObjects.visChart.getYAxisLabels(); const expectedLabels = await PageObjects.visChart.getExpectedValue( - ['1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'], + ['2,000', '4,000', '6,000', '8,000'], ['0', '1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'] ); expect(labels).to.eql(expectedLabels); @@ -243,19 +231,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const labels = await PageObjects.visChart.getYAxisLabels(); log.debug(labels); const expectedLabels = await PageObjects.visChart.getExpectedValue( - [ - '0', - '1,000', - '2,000', - '3,000', - '4,000', - '5,000', - '6,000', - '7,000', - '8,000', - '9,000', - '10,000', - ], + ['0', '2,000', '4,000', '6,000', '8,000', '10,000'], ['0', '1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'] ); expect(labels).to.eql(expectedLabels); @@ -266,7 +242,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickGo(); const labels = await PageObjects.visChart.getYAxisLabels(); const expectedLabels = await PageObjects.visChart.getExpectedValue( - ['1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'], + ['2,000', '4,000', '6,000', '8,000'], ['0', '1,000', '2,000', '3,000', '4,000', '5,000', '6,000', '7,000', '8,000', '9,000'] ); expect(labels).to.eql(expectedLabels); diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 91aec66966df00..e5f989747a9754 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -17,20 +17,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const security = getService('security'); - const PageObjects = getPageObjects([ - 'visualize', - 'visualBuilder', + const { timePicker, visChart, visualBuilder, visualize } = getPageObjects([ 'timePicker', 'visChart', - 'common', - 'settings', + 'visualBuilder', + 'visualize', ]); describe('visual builder', function describeIndexTests() { this.tags('includeFirefox'); before(async () => { - await PageObjects.visualize.initTests(); + await visualize.initTests(); }); beforeEach(async () => { @@ -38,16 +36,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ['kibana_admin', 'test_logstash_reader', 'kibana_sample_admin'], false ); - await PageObjects.visualize.navigateToNewVisualization(); - await PageObjects.visualize.clickVisualBuilder(); - await PageObjects.visualBuilder.checkVisualBuilderIsPresent(); + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); }); describe('metric', () => { - const { visualBuilder } = PageObjects; - beforeEach(async () => { - await visualBuilder.resetPage(); await visualBuilder.clickMetric(); await visualBuilder.checkMetricTabIsPresent(); await visualBuilder.clickPanelOptions('metric'); @@ -157,72 +153,188 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('gauge', () => { beforeEach(async () => { - await PageObjects.visualBuilder.resetPage(); - await PageObjects.visualBuilder.clickGauge(); - await PageObjects.visualBuilder.checkGaugeTabIsPresent(); + await visualBuilder.clickGauge(); + await visualBuilder.checkGaugeTabIsPresent(); }); it('should "Entire time range" selected as timerange mode for new visualization', async () => { - await PageObjects.visualBuilder.clickPanelOptions('gauge'); - await PageObjects.visualBuilder.checkSelectedDataTimerangeMode('Entire time range'); - await PageObjects.visualBuilder.clickDataTab('gauge'); + await visualBuilder.clickPanelOptions('gauge'); + await visualBuilder.checkSelectedDataTimerangeMode('Entire time range'); + await visualBuilder.clickDataTab('gauge'); }); it('should verify gauge label and count display', async () => { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const labelString = await PageObjects.visualBuilder.getGaugeLabel(); - expect(labelString).to.be('Count'); - const gaugeCount = await PageObjects.visualBuilder.getGaugeCount(); + await visChart.waitForVisualizationRenderingStabilized(); + const gaugeLabel = await visualBuilder.getGaugeLabel(); + const gaugeCount = await visualBuilder.getGaugeCount(); + expect(gaugeLabel).to.be('Count'); expect(gaugeCount).to.be('13,830'); }); + + it('should display correct data for max aggregation with entire time range mode', async () => { + await visualBuilder.selectAggType('Max'); + await visualBuilder.setFieldForAggregation('bytes'); + + const gaugeLabel = await visualBuilder.getGaugeLabel(); + const gaugeCount = await visualBuilder.getGaugeCount(); + + expect(gaugeLabel).to.be('Max of bytes'); + expect(gaugeCount).to.be('19,986'); + }); + + it('should display correct data for sum aggregation with last value time range mode', async () => { + await visualBuilder.selectAggType('Sum'); + await visualBuilder.setFieldForAggregation('memory'); + await visualBuilder.clickPanelOptions('gauge'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + + const gaugeLabel = await visualBuilder.getGaugeLabel(); + const gaugeCount = await visualBuilder.getGaugeCount(); + + expect(gaugeLabel).to.be('Sum of memory'); + expect(gaugeCount).to.be('672,320'); + }); + + it('should apply series color to gauge', async () => { + await visualBuilder.setColorPickerValue('#90CEEAFF'); + + const gaugeColor = await visualBuilder.getGaugeColor(); + expect(gaugeColor).to.be('rgb(144, 206, 234)'); + }); + + describe('Color rules', () => { + it('should apply color rules to visualization background and inner gauge circle', async () => { + await visualBuilder.selectAggType('Filter Ratio'); + await visualBuilder.setFilterRatioOption('Numerator', 'bytes < 0'); + await visualBuilder.clickPanelOptions('gauge'); + await visualBuilder.setColorRuleOperator('< less than'); + await visualBuilder.setColorRuleValue(21); + await visualBuilder.setBackgroundColor('#FFCFDF'); + await visualBuilder.setColorPickerValue('#AD7DE6', 1); + + const backGroundStyle = await visualBuilder.getBackgroundStyle(); + const gaugeInnerColor = await visualBuilder.getGaugeColor(true); + + expect(backGroundStyle).to.eql('background-color: rgb(255, 207, 223);'); + expect(gaugeInnerColor).to.eql('rgba(173,125,230,1)'); + }); + + it('should apply color rules to gauge and its value', async () => { + await visualBuilder.selectAggType('Cardinality'); + await visualBuilder.setFieldForAggregation('machine.ram'); + await visualBuilder.clickPanelOptions('gauge'); + await visualBuilder.setColorRuleOperator('>= greater than or equal'); + await visualBuilder.setColorRuleValue(20); + await visualBuilder.setColorPickerValue('#54B399', 2); + await visualBuilder.setColorPickerValue('#DA8B45', 3); + + const gaugeColor = await visualBuilder.getGaugeColor(); + const gaugeValueStyle = await visualBuilder.getGaugeValueStyle(); + + expect(gaugeColor).to.be('rgba(84,179,153,1)'); + expect(gaugeValueStyle).to.eql('color: rgb(218, 139, 69);'); + }); + }); }); describe('topN', () => { beforeEach(async () => { - await PageObjects.visualBuilder.resetPage(); - await PageObjects.visualBuilder.clickTopN(); - await PageObjects.visualBuilder.checkTopNTabIsPresent(); - await PageObjects.visualBuilder.clickPanelOptions('topN'); - await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); - await PageObjects.visualBuilder.setDropLastBucket(true); - await PageObjects.visualBuilder.clickDataTab('topN'); + await visualBuilder.clickTopN(); + await visualBuilder.checkTopNTabIsPresent(); + await visualBuilder.clickPanelOptions('topN'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.setDropLastBucket(true); + await visualBuilder.clickDataTab('topN'); }); it('should verify topN label and count display', async () => { - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const labelString = await PageObjects.visualBuilder.getTopNLabel(); - expect(labelString).to.be('Count'); - const gaugeCount = await PageObjects.visualBuilder.getTopNCount(); - expect(gaugeCount).to.be('156'); + await visChart.waitForVisualizationRenderingStabilized(); + const topNLabel = await visualBuilder.getTopNLabel(); + const topNCount = await visualBuilder.getTopNCount(); + expect(topNLabel).to.be('Count'); + expect(topNCount).to.be('156'); + }); + + it('should display correct data for counter rate aggregation with last value time range mode', async () => { + await visualBuilder.selectAggType('Counter rate'); + await visualBuilder.setFieldForAggregation('memory'); + + const topNLabel = await visualBuilder.getTopNLabel(); + const topNCount = await visualBuilder.getTopNCount(); + + expect(topNLabel).to.be('Counter Rate of memory'); + expect(topNCount).to.be('29,520'); + }); + + it('should display correct data for sum of squares aggregation with entire time range mode', async () => { + await visualBuilder.selectAggType('Sum of squares'); + await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.clickPanelOptions('topN'); + await visualBuilder.setMetricsDataTimerangeMode('Entire time range'); + + const topNLabel = await visualBuilder.getTopNLabel(); + const topNCount = await visualBuilder.getTopNCount(); + + expect(topNLabel).to.be('Sum of Sq. of bytes'); + expect(topNCount).to.be('630,170,001,503'); + }); + + it('should apply series color to bar', async () => { + await visualBuilder.cloneSeries(); + await visualBuilder.setColorPickerValue('#E5FFCF'); + await visualBuilder.setColorPickerValue('#80e08a', 1); + + const firstTopNBarStyle = await visualBuilder.getTopNBarStyle(); + const secondTopNBarStyle = await visualBuilder.getTopNBarStyle(1); + + expect(firstTopNBarStyle).to.contain('background-color: rgb(229, 255, 207);'); + expect(secondTopNBarStyle).to.contain('background-color: rgb(128, 224, 138);'); + }); + + describe('Color rules', () => { + it('should apply color rules to visualization background and bar', async () => { + await visualBuilder.selectAggType('Value Count'); + await visualBuilder.setFieldForAggregation('machine.ram'); + await visualBuilder.clickPanelOptions('topN'); + await visualBuilder.setColorRuleOperator('<= less than or equal'); + await visualBuilder.setColorRuleValue(153); + await visualBuilder.setBackgroundColor('#FBFFD4'); + await visualBuilder.setColorPickerValue('#D6BF57', 1); + + const backGroundStyle = await visualBuilder.getBackgroundStyle(); + const topNBarStyle = await visualBuilder.getTopNBarStyle(); + + expect(backGroundStyle).to.eql('background-color: rgb(251, 255, 212);'); + expect(topNBarStyle).to.contain('background-color: rgb(214, 191, 87);'); + }); }); }); describe('switch index pattern mode', () => { beforeEach(async () => { - await PageObjects.visualBuilder.resetPage(); - await PageObjects.visualBuilder.clickMetric(); - await PageObjects.visualBuilder.checkMetricTabIsPresent(); - await PageObjects.visualBuilder.clickPanelOptions('metric'); - await PageObjects.visualBuilder.setMetricsDataTimerangeMode('Last value'); - await PageObjects.visualBuilder.setDropLastBucket(true); - await PageObjects.visualBuilder.clickDataTab('metric'); - await PageObjects.timePicker.setAbsoluteRange( + await visualBuilder.clickMetric(); + await visualBuilder.checkMetricTabIsPresent(); + await visualBuilder.clickPanelOptions('metric'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.setDropLastBucket(true); + await visualBuilder.clickDataTab('metric'); + await timePicker.setAbsoluteRange( 'Sep 19, 2015 @ 06:31:44.000', 'Sep 22, 2015 @ 18:31:44.000' ); }); const switchIndexTest = async (useKibanaIndexes: boolean) => { - await PageObjects.visualBuilder.clickPanelOptions('metric'); - await PageObjects.visualBuilder.setIndexPatternValue('', false); + await visualBuilder.clickPanelOptions('metric'); + await visualBuilder.setIndexPatternValue('', false); // Sometimes popovers take some time to appear in Firefox (#71979) await retry.tryForTime(20000, async () => { - await PageObjects.visualBuilder.setIndexPatternValue('logstash-*', useKibanaIndexes); - await PageObjects.visualBuilder.waitForIndexPatternTimeFieldOptionsLoaded(); - await PageObjects.visualBuilder.selectIndexPatternTimeField('@timestamp'); + await visualBuilder.setIndexPatternValue('logstash-*', useKibanaIndexes); + await visualBuilder.waitForIndexPatternTimeFieldOptionsLoaded(); + await visualBuilder.selectIndexPatternTimeField('@timestamp'); }); - const newValue = await PageObjects.visualBuilder.getMetricValue(); + const newValue = await visualBuilder.getMetricValue(); expect(newValue).to.eql('156'); }; @@ -235,81 +347,108 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + describe('switch panel interval test', () => { + beforeEach(async () => { + await visualBuilder.resetPage(); + await visualBuilder.clickMetric(); + await visualBuilder.checkMetricTabIsPresent(); + await visualBuilder.clickPanelOptions('metric'); + await visualBuilder.setMetricsDataTimerangeMode('Last value'); + await visualBuilder.setDropLastBucket(true); + await timePicker.setAbsoluteRange( + 'Sep 19, 2015 @ 06:31:44.000', + 'Sep 22, 2015 @ 18:31:44.000' + ); + }); + + it('should be able to switch to gte interval (>=2d)', async () => { + await visualBuilder.setIntervalValue('>=2d'); + const newValue = await visualBuilder.getMetricValue(); + expect(newValue).to.eql('9,371'); + }); + + it('should be able to switch to fixed interval (1d)', async () => { + await visualBuilder.setIntervalValue('1d'); + const newValue = await visualBuilder.getMetricValue(); + expect(newValue).to.eql('4,614'); + }); + + it('should be able to switch to auto interval', async () => { + await visualBuilder.setIntervalValue('auto'); + const newValue = await visualBuilder.getMetricValue(); + expect(newValue).to.eql('156'); + }); + }); + describe('browser history changes', () => { it('should activate previous/next chart tab and panel config', async () => { - await PageObjects.visualBuilder.resetPage(); - log.debug('Click metric chart'); - await PageObjects.visualBuilder.clickMetric(); - await PageObjects.visualBuilder.checkMetricTabIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('metric'); + await visualBuilder.clickMetric(); + await visualBuilder.checkMetricTabIsPresent(); + await visualBuilder.checkTabIsSelected('metric'); log.debug('Click Top N chart'); - await PageObjects.visualBuilder.clickTopN(); - await PageObjects.visualBuilder.checkTopNTabIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('top_n'); + await visualBuilder.clickTopN(); + await visualBuilder.checkTopNTabIsPresent(); + await visualBuilder.checkTabIsSelected('top_n'); log.debug('Go back in browser history'); await browser.goBack(); log.debug('Check metric chart and panel config is rendered'); - await PageObjects.visualBuilder.checkMetricTabIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('metric'); - await PageObjects.visualBuilder.checkPanelConfigIsPresent('metric'); + await visualBuilder.checkMetricTabIsPresent(); + await visualBuilder.checkTabIsSelected('metric'); + await visualBuilder.checkPanelConfigIsPresent('metric'); log.debug('Go back in browser history'); await browser.goBack(); log.debug('Check timeseries chart and panel config is rendered'); await retry.try(async () => { - await PageObjects.visualBuilder.checkTimeSeriesChartIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('timeseries'); - await PageObjects.visualBuilder.checkPanelConfigIsPresent('timeseries'); + await visualBuilder.checkTimeSeriesChartIsPresent(); + await visualBuilder.checkTabIsSelected('timeseries'); + await visualBuilder.checkPanelConfigIsPresent('timeseries'); }); log.debug('Go forward in browser history'); await browser.goForward(); log.debug('Check metric chart and panel config is rendered'); - await PageObjects.visualBuilder.checkMetricTabIsPresent(); - await PageObjects.visualBuilder.checkTabIsSelected('metric'); - await PageObjects.visualBuilder.checkPanelConfigIsPresent('metric'); + await visualBuilder.checkMetricTabIsPresent(); + await visualBuilder.checkTabIsSelected('metric'); + await visualBuilder.checkPanelConfigIsPresent('metric'); }); it('should update panel config', async () => { - await PageObjects.visualBuilder.resetPage(); - const initialLegendItems = ['Count: 156']; const finalLegendItems = ['jpg: 106', 'css: 22', 'png: 14', 'gif: 8', 'php: 6']; log.debug('Group metrics by terms: extension.raw'); - await PageObjects.visualBuilder.clickPanelOptions('timeSeries'); - await PageObjects.visualBuilder.setDropLastBucket(true); - await PageObjects.visualBuilder.clickDataTab('timeSeries'); - await PageObjects.visualBuilder.setMetricsGroupByTerms('extension.raw'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const legendItems1 = await PageObjects.visualBuilder.getLegendItemsContent(); + await visualBuilder.clickPanelOptions('timeSeries'); + await visualBuilder.setDropLastBucket(true); + await visualBuilder.clickDataTab('timeSeries'); + await visualBuilder.setMetricsGroupByTerms('extension.raw'); + await visChart.waitForVisualizationRenderingStabilized(); + const legendItems1 = await visualBuilder.getLegendItemsContent(); expect(legendItems1).to.eql(finalLegendItems); log.debug('Go back in browser history'); await browser.goBack(); - const isTermsSelected = await PageObjects.visualBuilder.checkSelectedMetricsGroupByValue( - 'Terms' - ); + const isTermsSelected = await visualBuilder.checkSelectedMetricsGroupByValue('Terms'); expect(isTermsSelected).to.be(true); log.debug('Go back in browser history'); await browser.goBack(); - await PageObjects.visualBuilder.checkSelectedMetricsGroupByValue('Everything'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const legendItems2 = await PageObjects.visualBuilder.getLegendItemsContent(); + await visualBuilder.checkSelectedMetricsGroupByValue('Everything'); + await visChart.waitForVisualizationRenderingStabilized(); + const legendItems2 = await visualBuilder.getLegendItemsContent(); expect(legendItems2).to.eql(initialLegendItems); log.debug('Go forward twice in browser history'); await browser.goForward(); await browser.goForward(); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - const legendItems3 = await PageObjects.visualBuilder.getLegendItemsContent(); + await visChart.waitForVisualizationRenderingStabilized(); + const legendItems3 = await visualBuilder.getLegendItemsContent(); expect(legendItems3).to.eql(finalLegendItems); }); }); diff --git a/test/functional/apps/visualize/_tsvb_table.ts b/test/functional/apps/visualize/_tsvb_table.ts index de0771d3c8ec55..a29d8825068afa 100644 --- a/test/functional/apps/visualize/_tsvb_table.ts +++ b/test/functional/apps/visualize/_tsvb_table.ts @@ -78,6 +78,62 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const tableData = await visualBuilder.getViewTable(); expect(tableData).to.be(EXPECTED); }); + + it('should display correct values for variance aggregation', async () => { + const EXPECTED = + 'OS Variance of bytes\nwin 8 2,707,941.822\nwin xp 2,595,612.24\nwin 7 16,055,541.306\nios 6,505,206.56\nosx 1,016,620.667'; + await visualBuilder.selectAggType('Variance'); + await visualBuilder.setFieldForAggregation('bytes'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(EXPECTED); + }); + + it('should display correct values for filter ratio aggregation with numerator and denominator', async () => { + const EXPECTED = 'OS Filter Ratio\nwin 8 2\nwin xp 0\nwin 7 3\nios 0\nosx 0'; + await visualBuilder.selectAggType('Filter Ratio'); + await visualBuilder.setFilterRatioOption('Numerator', 'extension.raw : "css"'); + await visualBuilder.setFilterRatioOption('Denominator', 'bytes <= 3000'); + await visChart.waitForVisualizationRenderingStabilized(); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(EXPECTED); + }); + + it('should display correct values for average aggregation with last value time range mode', async () => { + const EXPECTED = + 'OS Average of machine.ram\nwin 8 13,958,643,712\nwin xp 14,602,888,806.4\nwin 7 14,048,122,197.333\nios 11,166,914,969.6\nosx 20,401,094,656'; + await visualBuilder.selectAggType('Average'); + await visualBuilder.setFieldForAggregation('machine.ram'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(EXPECTED); + }); + + it('should display correct values for sum aggregation with entire time range mode', async () => { + const EXPECTED = + 'OS Sum of memory\nwin 8 1,121,160\nwin xp 1,182,800\nwin 7 1,443,600\nios 971,360\nosx 858,480'; + await visualBuilder.selectAggType('Sum'); + await visualBuilder.setFieldForAggregation('memory'); + await visualBuilder.clickPanelOptions('table'); + await visualBuilder.setMetricsDataTimerangeMode('Entire time range'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(EXPECTED); + }); + + it('should display correct values for math aggregation', async () => { + const EXPECTED = 'OS Math\nwin 8 2,937\nwin xp 460\nwin 7 2,997\nios 1,095\nosx 1,724'; + await visualBuilder.selectAggType('Min'); + await visualBuilder.setFieldForAggregation('bytes'); + await visualBuilder.createNewAgg(); + await visualBuilder.selectAggType('math', 1); + await visualBuilder.fillInVariable('test', 'Min'); + await visualBuilder.fillInExpression('params.test + 1'); + + const tableData = await visualBuilder.getViewTable(); + expect(tableData).to.be(EXPECTED); + }); }); }); } diff --git a/test/functional/apps/visualize/_vega_chart.ts b/test/functional/apps/visualize/_vega_chart.ts index b2692c2a00d781..c52b0e0f8451f8 100644 --- a/test/functional/apps/visualize/_vega_chart.ts +++ b/test/functional/apps/visualize/_vega_chart.ts @@ -41,7 +41,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); - describe('vega chart in visualize app', () => { + // SKIPPED: https://github.com/elastic/kibana/issues/106352 + describe.skip('vega chart in visualize app', () => { before(async () => { await PageObjects.visualize.initTests(); log.debug('navigateToApp visualize'); diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index aed0bf5f0d56c9..c318635fc8548a 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -74,6 +74,7 @@ export class HomePageObject extends FtrService { async launchSampleDataSet(id: string) { await this.addSampleDataSet(id); + await this.common.closeToastIfExists(); await this.testSubjects.click(`launchSampleDataSet${id}`); } diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index fd89a88658b3a6..40a70efd93efdd 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -349,15 +349,21 @@ export class VisualBuilderPageObject extends FtrService { } public async getGaugeLabel() { - const gaugeLabel = await this.find.byCssSelector('.tvbVisGauge__label'); + const gaugeLabel = await this.testSubjects.find('gaugeLabel'); return await gaugeLabel.getVisibleText(); } public async getGaugeCount() { - const gaugeCount = await this.find.byCssSelector('.tvbVisGauge__value'); + const gaugeCount = await this.testSubjects.find('gaugeValue'); return await gaugeCount.getVisibleText(); } + public async getGaugeColor(isInner = false): Promise { + await this.visChart.waitForVisualizationRenderingStabilized(); + const gaugeColoredCircle = await this.testSubjects.find(`gaugeCircle${isInner ? 'Inner' : ''}`); + return await gaugeColoredCircle.getAttribute('stroke'); + } + public async clickTopN() { await this.testSubjects.click('top_nTsvbTypeBtn'); } @@ -372,6 +378,12 @@ export class VisualBuilderPageObject extends FtrService { return await gaugeCount.getVisibleText(); } + public async getTopNBarStyle(nth: number = 0): Promise { + await this.visChart.waitForVisualizationRenderingStabilized(); + const topNBars = await this.testSubjects.findAll('topNInnerBar'); + return await topNBars[nth].getAttribute('style'); + } + public async clickTable() { await this.testSubjects.click('tableTsvbTypeBtn'); } @@ -557,8 +569,8 @@ export class VisualBuilderPageObject extends FtrService { return (await label.findAllByTestSubject('comboBoxInput'))[1]; } - public async clickColorPicker(): Promise { - const picker = await this.find.byCssSelector('.tvbColorPicker button'); + public async clickColorPicker(nth: number = 0): Promise { + const picker = (await this.find.allByCssSelector('.tvbColorPicker button'))[nth]; await picker.clickMouseButton(); } @@ -576,10 +588,10 @@ export class VisualBuilderPageObject extends FtrService { } public async setColorPickerValue(colorHex: string, nth: number = 0): Promise { - const picker = await this.find.allByCssSelector('.tvbColorPicker button'); - await picker[nth].clickMouseButton(); + await this.clickColorPicker(nth); await this.checkColorPickerPopUpIsPresent(); await this.find.setValue('.euiColorPicker input', colorHex); + await this.clickColorPicker(nth); await this.visChart.waitForVisualizationRenderingStabilized(); } @@ -607,7 +619,13 @@ export class VisualBuilderPageObject extends FtrService { public async getMetricValueStyle(): Promise { await this.visChart.waitForVisualizationRenderingStabilized(); - const metricValue = await this.find.byCssSelector('[data-test-subj="tsvbMetricValue"]'); + const metricValue = await this.testSubjects.find('tsvbMetricValue'); + return await metricValue.getAttribute('style'); + } + + public async getGaugeValueStyle(): Promise { + await this.visChart.waitForVisualizationRenderingStabilized(); + const metricValue = await this.testSubjects.find('gaugeValue'); return await metricValue.getAttribute('style'); } @@ -727,4 +745,9 @@ export class VisualBuilderPageObject extends FtrService { await this.comboBox.set('topHitOrderByFieldSelect', timeField); }); } + + public async setFilterRatioOption(optionType: 'Numerator' | 'Denominator', query: string) { + const optionInput = await this.testSubjects.find(`filterRatio${optionType}Input`); + await optionInput.type(query); + } } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index e930406cdcce84..7e87312a709108 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -451,6 +451,14 @@ export class VisualizePageObject extends FtrService { await this.testSubjects.click('visualizesaveAndReturnButton'); } + public async getDeprecationWarningStatus() { + if (await this.visChart.isNewChartsLibraryEnabled()) { + await this.testSubjects.missingOrFail('vizDeprecationWarning'); + } else { + await this.testSubjects.existOrFail('vizDeprecationWarning'); + } + } + public async linkedToOriginatingApp() { await this.header.waitUntilLoadingHasFinished(); await this.testSubjects.existOrFail('visualizesaveAndReturnButton'); diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index f056c292b018f3..974fb8bf35ae0a 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -39,6 +39,7 @@ function getTShirtSizeByIdAndThreshold( export const alertType: AlertType< AlwaysFiringParams, + never, { count?: number }, { triggerdOnCycle: number }, never, diff --git a/x-pack/examples/alerting_example/server/alert_types/astros.ts b/x-pack/examples/alerting_example/server/alert_types/astros.ts index 8f9a2935183002..93bdeb2eada9cc 100644 --- a/x-pack/examples/alerting_example/server/alert_types/astros.ts +++ b/x-pack/examples/alerting_example/server/alert_types/astros.ts @@ -41,6 +41,7 @@ function getCraftFilter(craft: string) { export const alertType: AlertType< { outerSpaceCapacity: number; craft: string; op: string }, + never, { peopleInSpace: number }, { craft: string }, never, diff --git a/x-pack/plugins/actions/server/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client.mock.ts index 3795f013130524..aa766eba92eb31 100644 --- a/x-pack/plugins/actions/server/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client.mock.ts @@ -21,6 +21,7 @@ const createActionsClientMock = () => { getBulk: jest.fn(), execute: jest.fn(), enqueueExecution: jest.fn(), + ephemeralEnqueuedExecution: jest.fn(), listTypes: jest.fn(), isActionTypeEnabled: jest.fn(), }; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 012cd1a58de7e1..4b600d73ab0bd8 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -44,6 +44,7 @@ const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient() const actionExecutor = actionExecutorMock.create(); const authorization = actionsAuthorizationMock.create(); const executionEnqueuer = jest.fn(); +const ephemeralExecutionEnqueuer = jest.fn(); const request = httpServerMock.createKibanaRequest(); const auditLogger = auditServiceMock.create().asScoped(request); @@ -77,6 +78,7 @@ beforeEach(() => { preconfiguredActions: [], actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, auditLogger, @@ -453,6 +455,7 @@ describe('create()', () => { preconfiguredActions: [], actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, }); @@ -553,6 +556,7 @@ describe('get()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -608,6 +612,7 @@ describe('get()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -724,6 +729,7 @@ describe('get()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -793,6 +799,7 @@ describe('getAll()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -930,6 +937,7 @@ describe('getAll()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -1005,6 +1013,7 @@ describe('getBulk()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ @@ -1136,6 +1145,7 @@ describe('getBulk()', () => { defaultKibanaIndex, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index f8d13cdafa7557..66032a7c411bac 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -41,6 +41,7 @@ import { AuthorizationMode, } from './authorization/get_authorization_mode_by_source'; import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events'; +import { RunNowResult } from '../../task_manager/server'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -68,7 +69,8 @@ interface ConstructorOptions { unsecuredSavedObjectsClient: SavedObjectsClientContract; preconfiguredActions: PreConfiguredAction[]; actionExecutor: ActionExecutorContract; - executionEnqueuer: ExecutionEnqueuer; + executionEnqueuer: ExecutionEnqueuer; + ephemeralExecutionEnqueuer: ExecutionEnqueuer; request: KibanaRequest; authorization: ActionsAuthorization; auditLogger?: AuditLogger; @@ -88,7 +90,8 @@ export class ActionsClient { private readonly actionExecutor: ActionExecutorContract; private readonly request: KibanaRequest; private readonly authorization: ActionsAuthorization; - private readonly executionEnqueuer: ExecutionEnqueuer; + private readonly executionEnqueuer: ExecutionEnqueuer; + private readonly ephemeralExecutionEnqueuer: ExecutionEnqueuer; private readonly auditLogger?: AuditLogger; constructor({ @@ -99,6 +102,7 @@ export class ActionsClient { preconfiguredActions, actionExecutor, executionEnqueuer, + ephemeralExecutionEnqueuer, request, authorization, auditLogger, @@ -110,6 +114,7 @@ export class ActionsClient { this.preconfiguredActions = preconfiguredActions; this.actionExecutor = actionExecutor; this.executionEnqueuer = executionEnqueuer; + this.ephemeralExecutionEnqueuer = ephemeralExecutionEnqueuer; this.request = request; this.authorization = authorization; this.auditLogger = auditLogger; @@ -497,6 +502,17 @@ export class ActionsClient { return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options); } + public async ephemeralEnqueuedExecution(options: EnqueueExecutionOptions): Promise { + const { source } = options; + if ( + (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === + AuthorizationMode.RBAC + ) { + await this.authorization.ensureAuthorized('execute'); + } + return this.ephemeralExecutionEnqueuer(this.unsecuredSavedObjectsClient, options); + } + public async listTypes(): Promise { return this.actionTypeRegistry.list(); } diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 7dcd66c711bdde..bcad5f20d9ba77 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -6,8 +6,13 @@ */ import { SavedObjectsClientContract } from '../../../../src/core/server'; -import { TaskManagerStartContract } from '../../task_manager/server'; -import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './types'; +import { RunNowResult, TaskManagerStartContract } from '../../task_manager/server'; +import { + RawAction, + ActionTypeRegistryContract, + PreConfiguredAction, + ActionTaskExecutorParams, +} from './types'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './constants/saved_objects'; import { ExecuteOptions as ActionExecutorOptions } from './lib/action_executor'; import { isSavedObjectExecutionSource } from './lib'; @@ -27,17 +32,17 @@ export interface ExecuteOptions extends Pick = ( unsecuredSavedObjectsClient: SavedObjectsClientContract, options: ExecuteOptions -) => Promise; +) => Promise; export function createExecutionEnqueuerFunction({ taskManager, actionTypeRegistry, isESOCanEncrypt, preconfiguredActions, -}: CreateExecuteFunctionOptions) { +}: CreateExecuteFunctionOptions): ExecutionEnqueuer { return async function execute( unsecuredSavedObjectsClient: SavedObjectsClientContract, { id, params, spaceId, source, apiKey, relatedSavedObjects }: ExecuteOptions @@ -48,18 +53,10 @@ export function createExecutionEnqueuerFunction({ ); } - const { actionTypeId, name, isMissingSecrets } = await getAction( - unsecuredSavedObjectsClient, - preconfiguredActions, - id - ); - - if (isMissingSecrets) { - throw new Error( - `Unable to execute action because no secrets are defined for the "${name}" connector.` - ); - } + const action = await getAction(unsecuredSavedObjectsClient, preconfiguredActions, id); + validateCanActionBeUsed(action); + const { actionTypeId } = action; if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) { actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); } @@ -76,7 +73,7 @@ export function createExecutionEnqueuerFunction({ ); await taskManager.schedule({ - taskType: `actions:${actionTypeId}`, + taskType: `actions:${action.actionTypeId}`, params: { spaceId, actionTaskParamsId: actionTaskParamsRecord.id, @@ -87,6 +84,53 @@ export function createExecutionEnqueuerFunction({ }; } +export function createEphemeralExecutionEnqueuerFunction({ + taskManager, + actionTypeRegistry, + preconfiguredActions, +}: CreateExecuteFunctionOptions): ExecutionEnqueuer { + return async function execute( + unsecuredSavedObjectsClient: SavedObjectsClientContract, + { id, params, spaceId, source, apiKey }: ExecuteOptions + ): Promise { + const action = await getAction(unsecuredSavedObjectsClient, preconfiguredActions, id); + validateCanActionBeUsed(action); + + const { actionTypeId } = action; + if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) { + actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + } + + const taskParams: ActionTaskExecutorParams = { + spaceId, + taskParams: { + actionId: id, + // Saved Objects won't allow us to enforce unknown rather than any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: params as Record, + ...(apiKey ? { apiKey } : {}), + }, + ...executionSourceAsSavedObjectReferences(source), + }; + + return taskManager.ephemeralRunNow({ + taskType: `actions:${action.actionTypeId}`, + params: taskParams, + state: {}, + scope: ['actions'], + }); + }; +} + +function validateCanActionBeUsed(action: PreConfiguredAction | RawAction) { + const { name, isMissingSecrets } = action; + if (isMissingSecrets) { + throw new Error( + `Unable to execute action because no secrets are defined for the "${name}" connector.` + ); + } +} + function executionSourceAsSavedObjectReferences(executionSource: ActionExecutorOptions['source']) { return isSavedObjectExecutionSource(executionSource) ? { diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 9e62b123951df4..5dfe56cff50165 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -48,6 +48,7 @@ export interface TaskInfo { export interface ExecuteOptions { actionId: string; + isEphemeral?: boolean; request: KibanaRequest; params: Record; source?: ActionExecutionSource; @@ -79,6 +80,7 @@ export class ActionExecutor { params, request, source, + isEphemeral, taskInfo, relatedSavedObjects, }: ExecuteOptions): Promise> { @@ -207,6 +209,7 @@ export class ActionExecutor { params: validatedParams, config: validatedConfig, secrets: validatedSecrets, + isEphemeral, }); } catch (err) { rawResult = { diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 495d638951b56d..722ba08a26258a 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -125,6 +125,7 @@ test('executes the task by calling the executor with proper parameters', async ( expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', + isEphemeral: false, params: { baz: true }, relatedSavedObjects: [], request: expect.objectContaining({ @@ -250,6 +251,7 @@ test('uses API key when provided', async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', + isEphemeral: false, params: { baz: true }, relatedSavedObjects: [], request: expect.objectContaining({ @@ -293,6 +295,7 @@ test('uses relatedSavedObjects when provided', async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', + isEphemeral: false, params: { baz: true }, relatedSavedObjects: [ { @@ -334,14 +337,15 @@ test('sanitizes invalid relatedSavedObjects when provided', async () => { await taskRunner.run(); expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', + isEphemeral: false, params: { baz: true }, - relatedSavedObjects: [], request: expect.objectContaining({ headers: { // base64 encoded "123:abc" authorization: 'ApiKey MTIzOmFiYw==', }, }), + relatedSavedObjects: [], taskInfo: { scheduled: new Date(), }, @@ -369,6 +373,7 @@ test(`doesn't use API key when not provided`, async () => { expect(mockedActionExecutor.execute).toHaveBeenCalledWith({ actionId: '2', + isEphemeral: false, params: { baz: true }, relatedSavedObjects: [], request: expect.objectContaining({ diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index 64169de728f75a..2354ea55eded67 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -16,6 +16,7 @@ import { KibanaRequest, SavedObjectReference, IBasePath, + SavedObject, } from '../../../../../src/core/server'; import { ActionExecutorContract } from './action_executor'; import { ExecutorError } from './executor_error'; @@ -27,6 +28,8 @@ import { ActionTypeRegistryContract, SpaceIdToNamespaceFunction, ActionTypeExecutorResult, + ActionTaskExecutorParams, + isPersistedActionTask, } from '../types'; import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; import { asSavedObjectExecutionSource } from './action_execution_source'; @@ -78,16 +81,16 @@ export class TaskRunnerFactory { return { async run() { - const { spaceId, actionTaskParamsId } = taskInstance.params as Record; - const namespace = spaceIdToNamespace(spaceId); + const actionTaskExecutorParams = taskInstance.params as ActionTaskExecutorParams; + const { spaceId } = actionTaskExecutorParams; const { attributes: { actionId, params, apiKey, relatedSavedObjects }, references, - } = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( - ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - actionTaskParamsId, - { namespace } + } = await getActionTaskParams( + actionTaskExecutorParams, + encryptedSavedObjectsClient, + spaceIdToNamespace ); const requestHeaders: Record = {}; @@ -119,7 +122,8 @@ export class TaskRunnerFactory { try { executorResult = await actionExecutor.execute({ params, - actionId, + actionId: actionId as string, + isEphemeral: !isPersistedActionTask(actionTaskExecutorParams), request: fakeRequest, ...getSourceFromReferences(references), taskInfo, @@ -144,26 +148,46 @@ export class TaskRunnerFactory { } // Cleanup action_task_params object now that we're done with it - try { - // If the request has reached this far we can assume the user is allowed to run clean up - // We would idealy secure every operation but in order to support clean up of legacy alerts - // we allow this operation in an unsecured manner - // Once support for legacy alert RBAC is dropped, this can be secured - await getUnsecuredSavedObjectsClient(fakeRequest).delete( - ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, - actionTaskParamsId - ); - } catch (e) { - // Log error only, we shouldn't fail the task because of an error here (if ever there's retry logic) - logger.error( - `Failed to cleanup ${ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE} object [id="${actionTaskParamsId}"]: ${e.message}` - ); + if (isPersistedActionTask(actionTaskExecutorParams)) { + try { + // If the request has reached this far we can assume the user is allowed to run clean up + // We would idealy secure every operation but in order to support clean up of legacy alerts + // we allow this operation in an unsecured manner + // Once support for legacy alert RBAC is dropped, this can be secured + await getUnsecuredSavedObjectsClient(fakeRequest).delete( + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + actionTaskExecutorParams.actionTaskParamsId + ); + } catch (e) { + // Log error only, we shouldn't fail the task because of an error here (if ever there's retry logic) + logger.error( + `Failed to cleanup ${ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE} object [id="${actionTaskExecutorParams.actionTaskParamsId}"]: ${e.message}` + ); + } } }, }; } } +async function getActionTaskParams( + executorParams: ActionTaskExecutorParams, + encryptedSavedObjectsClient: EncryptedSavedObjectsClient, + spaceIdToNamespace: SpaceIdToNamespaceFunction +): Promise, 'id' | 'type'>> { + const { spaceId } = executorParams; + const namespace = spaceIdToNamespace(spaceId); + if (isPersistedActionTask(executorParams)) { + return encryptedSavedObjectsClient.getDecryptedAsInternalUser( + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + executorParams.actionTaskParamsId, + { namespace } + ); + } else { + return { attributes: executorParams.taskParams, references: executorParams.references ?? [] }; + } +} + function getSourceFromReferences(references: SavedObjectReference[]) { return pipe( fromNullable(references.find((ref) => ref.name === 'source')), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 2c5287525c5974..2f4b1325e3df3d 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -38,7 +38,10 @@ import { ActionsConfig, getValidatedConfig } from './config'; import { resolveCustomHosts } from './lib/custom_host_settings'; import { ActionsClient } from './actions_client'; import { ActionTypeRegistry } from './action_type_registry'; -import { createExecutionEnqueuerFunction } from './create_execute_function'; +import { + createExecutionEnqueuerFunction, + createEphemeralExecutionEnqueuerFunction, +} from './create_execute_function'; import { registerBuiltInActionTypes } from './builtin_action_types'; import { registerActionsUsageCollector } from './usage'; import { @@ -332,6 +335,12 @@ export class ActionsPlugin implements Plugin { config: Config; secrets: Secrets; params: Params; + isEphemeral?: boolean; } export interface ActionResult { @@ -132,10 +134,25 @@ export interface ActionTaskParams extends SavedObjectAttributes { apiKey?: string; } -export interface ActionTaskExecutorParams { +interface PersistedActionTaskExecutorParams { spaceId: string; actionTaskParamsId: string; } +interface EphemeralActionTaskExecutorParams { + spaceId: string; + taskParams: ActionTaskParams; + references?: SavedObjectReference[]; +} + +export type ActionTaskExecutorParams = + | PersistedActionTaskExecutorParams + | EphemeralActionTaskExecutorParams; + +export function isPersistedActionTask( + actionTask: ActionTaskExecutorParams +): actionTask is PersistedActionTaskExecutorParams { + return typeof (actionTask as PersistedActionTaskExecutorParams).actionTaskParamsId === 'string'; +} export interface ProxySettings { proxyUrl: string; diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 62d2f2b57b8e87..215ff9164c1a72 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -118,6 +118,8 @@ The following table describes the properties of the `options` object. |executor|This is where the code for the rule type lives. This is a function to be called when executing a rule on an interval basis. For full details, see the executor section below.|Function| |producer|The id of the application producing this rule type.|string| |minimumLicenseRequired|The value of a minimum license. Most of the rules are licensed as "basic".|string| +|useSavedObjectReferences.extractReferences|(Optional) When developing a rule type, you can choose to implement hooks for extracting saved object references from rule parameters. This hook will be invoked when a rule is created or updated. Implementing this hook is optional, but if an extract hook is implemented, an inject hook must also be implemented.|Function +|useSavedObjectReferences.injectReferences|(Optional) When developing a rule type, you can choose to implement hooks for injecting saved object references into rule parameters. This hook will be invoked when a rule is retrieved (get or find). Implementing this hook is optional, but if an inject hook is implemented, an extract hook must also be implemented.|Function |isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean| ### Executor @@ -173,6 +175,19 @@ For example, if the `context` has one variable `foo` which is an object that has } ``` +### useSavedObjectReferences Hooks + +This is an optional pair of functions that can be implemented by a rule type. Both `extractReferences` and `injectReferences` functions must be implemented if either is impemented. + +**useSavedObjectReferences.extractReferences** + +This function should take the rule type params as input and extract out any saved object IDs stored within the params. For each saved object ID, a new saved object reference should be created and a saved object reference should replace the saved object ID in the rule params. This function should return the modified rule type params (with saved object reference name, not IDs) and an array of saved object references. + + +**useSavedObjectReferences.injectReferences** + + +This function should take the rule type params (with saved object references) and the saved object references array as input and inject the saved object ID in place of any saved object references in the rule type params. Note that any error thrown within this function will be propagated. ## Licensing Currently most rule types are free features. But some rule types are subscription features, such as the tracking containment rule. @@ -210,6 +225,13 @@ import { interface MyRuleTypeParams extends AlertTypeParams { server: string; threshold: number; + testSavedObjectId: string; +} + +interface MyRuleTypeExtractedParams extends AlertTypeParams { + server: string; + threshold: number; + testSavedObjectRef: string; } interface MyRuleTypeState extends AlertTypeState { @@ -229,6 +251,7 @@ type MyRuleTypeActionGroups = 'default' | 'warning'; const myRuleType: AlertType< MyRuleTypeParams, + MyRuleTypeExtractedParams, MyRuleTypeState, MyRuleTypeAlertState, MyRuleTypeAlertContext, @@ -274,6 +297,7 @@ const myRuleType: AlertType< rule, }: AlertExecutorOptions< MyRuleTypeParams, + MyRuleTypeExtractedParams, MyRuleTypeState, MyRuleTypeAlertState, MyRuleTypeAlertContext, @@ -320,6 +344,29 @@ const myRuleType: AlertType< }; }, producer: 'alerting', + useSavedObjectReferences: { + extractReferences: (params: Params): RuleParamsAndRefs => { + const { testSavedObjectId, ...otherParams } = params; + + const testSavedObjectRef = 'testRef_0'; + const references = [ + { + name: `testRef_0`, + id: testSavedObjectId, + type: 'index-pattern', + }, + ]; + return { params: { ...otherParams, testSavedObjectRef }, references }; + }, + injectReferences: (params: SavedObjectAttributes, references: SavedObjectReference[]) => { + const { testSavedObjectRef, ...otherParams } = params; + const reference = references.find((ref) => ref.name === testSavedObjectRef); + if (!reference) { + throw new Error(`Test reference "${testSavedObjectRef}"`); + } + return { ...otherParams, testSavedObjectId: reference.id } as Params; + }, + } }; server.newPlatform.setup.plugins.alerting.registerType(myRuleType); diff --git a/x-pack/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/plugins/alerting/server/alert_type_registry.test.ts index 63e381bc66c0ae..835c98b9e03c50 100644 --- a/x-pack/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/alert_type_registry.test.ts @@ -57,7 +57,7 @@ describe('has()', () => { describe('register()', () => { test('throws if AlertType Id contains invalid characters', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -90,7 +90,7 @@ describe('register()', () => { }); test('throws if AlertType Id isnt a string', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: (123 as unknown) as string, name: 'Test', actionGroups: [ @@ -113,7 +113,7 @@ describe('register()', () => { }); test('throws if AlertType action groups contains reserved group id', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -146,7 +146,7 @@ describe('register()', () => { }); test('allows an AlertType to specify a custom recovery group', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -187,6 +187,7 @@ describe('register()', () => { never, never, never, + never, 'default' | 'backToAwesome', 'backToAwesome' > = { @@ -222,7 +223,7 @@ describe('register()', () => { }); test('registers the executor with the task manager', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -253,7 +254,7 @@ describe('register()', () => { }); test('shallow clones the given alert type', () => { - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -506,8 +507,8 @@ function alertTypeWithVariables( id: ActionGroupIds, context: string, state: string -): AlertType { - const baseAlert: AlertType = { +): AlertType { + const baseAlert: AlertType = { id, name: `${id}-name`, actionGroups: [], diff --git a/x-pack/plugins/alerting/server/alert_type_registry.ts b/x-pack/plugins/alerting/server/alert_type_registry.ts index 64fca58c25e66e..f77dd3f7e46eca 100644 --- a/x-pack/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/plugins/alerting/server/alert_type_registry.ts @@ -74,6 +74,7 @@ const alertIdSchema = schema.string({ export type NormalizedAlertType< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -82,13 +83,22 @@ export type NormalizedAlertType< > = { actionGroups: Array>; } & Omit< - AlertType, + AlertType< + Params, + ExtractedParams, + State, + InstanceState, + InstanceContext, + ActionGroupIds, + RecoveryActionGroupId + >, 'recoveryActionGroup' | 'actionGroups' > & Pick< Required< AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -100,6 +110,7 @@ export type NormalizedAlertType< >; export type UntypedNormalizedAlertType = NormalizedAlertType< + AlertTypeParams, AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -132,6 +143,7 @@ export class AlertTypeRegistry { public register< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -140,6 +152,7 @@ export class AlertTypeRegistry { >( alertType: AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -161,6 +174,7 @@ export class AlertTypeRegistry { const normalizedAlertType = augmentActionGroupsWithReserved< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -179,6 +193,7 @@ export class AlertTypeRegistry { createTaskRunner: (context: RunContext) => this.taskRunnerFactory.create< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -198,6 +213,7 @@ export class AlertTypeRegistry { public get< Params extends AlertTypeParams = AlertTypeParams, + ExtractedParams extends AlertTypeParams = AlertTypeParams, State extends AlertTypeState = AlertTypeState, InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, @@ -207,6 +223,7 @@ export class AlertTypeRegistry { id: string ): NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -230,6 +247,7 @@ export class AlertTypeRegistry { */ return (this.alertTypes.get(id)! as unknown) as NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -284,6 +302,7 @@ function normalizedActionVariables(actionVariables: AlertType['actionVariables'] function augmentActionGroupsWithReserved< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -292,6 +311,7 @@ function augmentActionGroupsWithReserved< >( alertType: AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -300,6 +320,7 @@ function augmentActionGroupsWithReserved< > ): NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index 53d888967c431f..3b121413e489bd 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -16,6 +16,7 @@ import { SavedObject, PluginInitializerContext, SavedObjectsUtils, + SavedObjectAttributes, } from '../../../../../src/core/server'; import { esKuery } from '../../../../../src/plugins/data/server'; import { ActionsClient, ActionsAuthorization } from '../../../actions/server'; @@ -183,6 +184,9 @@ export interface GetAlertInstanceSummaryParams { dateStart?: string; } +// NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects +const extractedSavedObjectParamReferenceNamePrefix = 'param:'; + const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = { type: AlertingAuthorizationFilterType.KQL, fieldNames: { ruleTypeId: 'alert.attributes.alertTypeId', consumer: 'alert.attributes.consumer' }, @@ -284,9 +288,14 @@ export class AlertsClient { await this.validateActions(alertType, data.actions); - const createTime = Date.now(); - const { references, actions } = await this.denormalizeActions(data.actions); + // Extract saved object references for this rule + const { references, params: updatedParams, actions } = await this.extractReferences( + alertType, + data.actions, + validatedAlertTypeParams + ); + const createTime = Date.now(); const notifyWhen = getAlertNotifyWhenType(data.notifyWhen, data.throttle); const rawAlert: RawAlert = { @@ -297,7 +306,7 @@ export class AlertsClient { updatedBy: username, createdAt: new Date(createTime).toISOString(), updatedAt: new Date(createTime).toISOString(), - params: validatedAlertTypeParams as RawAlert['params'], + params: updatedParams as RawAlert['params'], muteAll: false, mutedInstanceIds: [], notifyWhen, @@ -357,7 +366,12 @@ export class AlertsClient { }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; } - return this.getAlertFromRaw(createdAlert.id, createdAlert.attributes, references); + return this.getAlertFromRaw( + createdAlert.id, + createdAlert.attributes.alertTypeId, + createdAlert.attributes, + references + ); } public async get({ @@ -389,7 +403,12 @@ export class AlertsClient { savedObject: { type: 'alert', id }, }) ); - return this.getAlertFromRaw(result.id, result.attributes, result.references); + return this.getAlertFromRaw( + result.id, + result.attributes.alertTypeId, + result.attributes, + result.references + ); } public async getAlertState({ id }: { id: string }): Promise { @@ -518,6 +537,7 @@ export class AlertsClient { } return this.getAlertFromRaw( id, + attributes.alertTypeId, fields ? (pick(attributes, fields) as RawAlert) : attributes, references ); @@ -760,7 +780,13 @@ export class AlertsClient { ); await this.validateActions(alertType, data.actions); - const { actions, references } = await this.denormalizeActions(data.actions); + // Extract saved object references for this rule + const { references, params: updatedParams, actions } = await this.extractReferences( + alertType, + data.actions, + validatedAlertTypeParams + ); + const username = await this.getUserName(); let createdAPIKey = null; @@ -780,7 +806,7 @@ export class AlertsClient { ...attributes, ...data, ...apiKeyAttributes, - params: validatedAlertTypeParams as RawAlert['params'], + params: updatedParams as RawAlert['params'], actions, notifyWhen, updatedBy: username, @@ -807,7 +833,12 @@ export class AlertsClient { throw e; } - return this.getPartialAlertFromRaw(id, updatedObject.attributes, updatedObject.references); + return this.getPartialAlertFromRaw( + id, + alertType, + updatedObject.attributes, + updatedObject.references + ); } private apiKeyAsAlertAttributes( @@ -1436,18 +1467,29 @@ export class AlertsClient { private getAlertFromRaw( id: string, + ruleTypeId: string, rawAlert: RawAlert, references: SavedObjectReference[] | undefined ): Alert { + const ruleType = this.alertTypeRegistry.get(ruleTypeId); // In order to support the partial update API of Saved Objects we have to support // partial updates of an Alert, but when we receive an actual RawAlert, it is safe // to cast the result to an Alert - return this.getPartialAlertFromRaw(id, rawAlert, references) as Alert; + return this.getPartialAlertFromRaw(id, ruleType, rawAlert, references) as Alert; } private getPartialAlertFromRaw( id: string, - { createdAt, updatedAt, meta, notifyWhen, scheduledTaskId, ...rawAlert }: Partial, + ruleType: UntypedNormalizedAlertType, + { + createdAt, + updatedAt, + meta, + notifyWhen, + scheduledTaskId, + params, + ...rawAlert + }: Partial, references: SavedObjectReference[] | undefined ): PartialAlert { // Not the prettiest code here, but if we want to use most of the @@ -1460,6 +1502,7 @@ export class AlertsClient { }; delete rawAlertWithoutExecutionStatus.executionStatus; const executionStatus = alertExecutionStatusFromRaw(this.logger, id, rawAlert.executionStatus); + return { id, notifyWhen, @@ -1470,6 +1513,7 @@ export class AlertsClient { actions: rawAlert.actions ? this.injectReferencesIntoActions(id, rawAlert.actions, references || []) : [], + params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params, ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), ...(createdAt ? { createdAt: new Date(createdAt) } : {}), ...(scheduledTaskId ? { scheduledTaskId } : {}), @@ -1525,6 +1569,73 @@ export class AlertsClient { } } + private async extractReferences< + Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams + >( + ruleType: UntypedNormalizedAlertType, + ruleActions: NormalizedAlertAction[], + ruleParams: Params + ): Promise<{ + actions: RawAlert['actions']; + params: ExtractedParams; + references: SavedObjectReference[]; + }> { + const { references: actionReferences, actions } = await this.denormalizeActions(ruleActions); + + // Extracts any references using configured reference extractor if available + const extractedRefsAndParams = ruleType?.useSavedObjectReferences?.extractReferences + ? ruleType.useSavedObjectReferences.extractReferences(ruleParams) + : null; + const extractedReferences = extractedRefsAndParams?.references ?? []; + const params = (extractedRefsAndParams?.params as ExtractedParams) ?? ruleParams; + + // Prefix extracted references in order to avoid clashes with framework level references + const paramReferences = extractedReferences.map((reference: SavedObjectReference) => ({ + ...reference, + name: `${extractedSavedObjectParamReferenceNamePrefix}${reference.name}`, + })); + + const references = [...actionReferences, ...paramReferences]; + + return { + actions, + params, + references, + }; + } + + private injectReferencesIntoParams< + Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams + >( + ruleId: string, + ruleType: UntypedNormalizedAlertType, + ruleParams: SavedObjectAttributes | undefined, + references: SavedObjectReference[] + ): Params { + try { + const paramReferences = references + .filter((reference: SavedObjectReference) => + reference.name.startsWith(extractedSavedObjectParamReferenceNamePrefix) + ) + .map((reference: SavedObjectReference) => ({ + ...reference, + name: reference.name.replace(extractedSavedObjectParamReferenceNamePrefix, ''), + })); + return ruleParams && ruleType?.useSavedObjectReferences?.injectReferences + ? (ruleType.useSavedObjectReferences.injectReferences( + ruleParams as ExtractedParams, + paramReferences + ) as Params) + : (ruleParams as Params); + } catch (err) { + throw Boom.badRequest( + `Error injecting reference into rule params for rule id ${ruleId} - ${err.message}` + ); + } + } + private async denormalizeActions( alertActions: NormalizedAlertAction[] ): Promise<{ actions: RawAlert['actions']; references: SavedObjectReference[] }> { diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts index e231d1e3c27a29..3275e25b85df5b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/create.test.ts @@ -801,6 +801,360 @@ describe('create()', () => { expect(taskManager.schedule).toHaveBeenCalledTimes(0); }); + test('should call useSavedObjectReferences.extractReferences and useSavedObjectReferences.injectReferences if defined for rule type', async () => { + const ruleParams = { + bar: true, + parameterThatIsSavedObjectId: '9', + }; + const extractReferencesFn = jest.fn().mockReturnValue({ + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + references: [ + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + alertTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: extractReferencesFn, + injectReferences: injectReferencesFn, + }, + })); + const data = getMockData({ + params: ruleParams, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + scheduledTaskId: 'task-123', + }, + references: [], + }); + const result = await alertsClient.create({ data }); + + expect(extractReferencesFn).toHaveBeenCalledWith(ruleParams); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { actionRef: 'action_0', actionTypeId: 'test', group: 'default', params: { foo: true } }, + ], + alertTypeId: '123', + apiKey: null, + apiKeyOwner: null, + consumer: 'bar', + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + enabled: true, + executionStatus: { + error: null, + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + muteAll: false, + mutedInstanceIds: [], + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true, parameterThatIsSavedObjectRef: 'soRef_0' }, + schedule: { interval: '10s' }, + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: 'mock-saved-object-id', + references: [ + { id: '1', name: 'action_0', type: 'action' }, + { id: '9', name: 'param:soRef_0', type: 'someSavedObjectType' }, + ], + } + ); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + + test('should allow rule types to use action_ prefix for saved object reference names', async () => { + const ruleParams = { + bar: true, + parameterThatIsSavedObjectId: '8', + }; + const extractReferencesFn = jest.fn().mockReturnValue({ + params: { + bar: true, + parameterThatIsSavedObjectRef: 'action_0', + }, + references: [ + { + name: 'action_0', + type: 'someSavedObjectType', + id: '8', + }, + ], + }); + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '8', + }); + alertTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: extractReferencesFn, + injectReferences: injectReferencesFn, + }, + })); + const data = getMockData({ + params: ruleParams, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'action_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:action_0', + type: 'someSavedObjectType', + id: '8', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + scheduledTaskId: 'task-123', + }, + references: [], + }); + const result = await alertsClient.create({ data }); + + expect(extractReferencesFn).toHaveBeenCalledWith(ruleParams); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { actionRef: 'action_0', actionTypeId: 'test', group: 'default', params: { foo: true } }, + ], + alertTypeId: '123', + apiKey: null, + apiKeyOwner: null, + consumer: 'bar', + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + enabled: true, + executionStatus: { + error: null, + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + muteAll: false, + mutedInstanceIds: [], + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true, parameterThatIsSavedObjectRef: 'action_0' }, + schedule: { interval: '10s' }, + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: 'mock-saved-object-id', + references: [ + { id: '1', name: 'action_0', type: 'action' }, + { id: '8', name: 'param:action_0', type: 'someSavedObjectType' }, + ], + } + ); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'action_0', + }, + [{ id: '8', name: 'action_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "8", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + test('should trim alert name when creating API key', async () => { const data = getMockData({ name: ' my alert name ' }); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts index 5ec39681a758bb..0633dda8e7a59f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/find.test.ts @@ -191,6 +191,335 @@ describe('find()', () => { expect(jest.requireMock('../lib/map_sort_field').mapSortField).toHaveBeenCalledWith('name'); }); + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { + jest.resetAllMocks(); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureRuleTypeIsAuthorized() {}, + logSuccessfulAuthorization() {}, + }); + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + alertTypeRegistry.list.mockReturnValue( + new Set([ + ...listedTypes, + { + actionGroups: [], + recoveryActionGroup: RecoveredActionGroup, + actionVariables: undefined, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + id: '123', + name: 'myType', + producer: 'myApp', + enabledInLicense: true, + }, + ]) + ); + alertTypeRegistry.get.mockImplementationOnce(() => ({ + id: 'myType', + name: 'myType', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'myApp', + })); + alertTypeRegistry.get.mockImplementationOnce(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + unsecuredSavedObjectsClient.find.mockResolvedValue({ + total: 2, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + { + id: '2', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '20s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + ], + }); + const alertsClient = new AlertsClient(alertsClientParams); + const result = await alertsClient.find({ options: {} }); + + expect(injectReferencesFn).toHaveBeenCalledTimes(1); + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "data": Array [ + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "myType", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + }, + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "2", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "20s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + }, + ], + "page": 1, + "perPage": 10, + "total": 2, + } + `); + }); + + test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => { + jest.resetAllMocks(); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureRuleTypeIsAuthorized() {}, + logSuccessfulAuthorization() {}, + }); + const injectReferencesFn = jest.fn().mockImplementation(() => { + throw new Error('something went wrong!'); + }); + alertTypeRegistry.list.mockReturnValue( + new Set([ + ...listedTypes, + { + actionGroups: [], + recoveryActionGroup: RecoveredActionGroup, + actionVariables: undefined, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + id: '123', + name: 'myType', + producer: 'myApp', + enabledInLicense: true, + }, + ]) + ); + alertTypeRegistry.get.mockImplementationOnce(() => ({ + id: 'myType', + name: 'myType', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'myApp', + })); + alertTypeRegistry.get.mockImplementationOnce(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + unsecuredSavedObjectsClient.find.mockResolvedValue({ + total: 2, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + { + id: '2', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '20s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + score: 1, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }, + ], + }); + const alertsClient = new AlertsClient(alertsClientParams); + await expect(alertsClient.find({ options: {} })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error injecting reference into rule params for rule id 2 - something went wrong!"` + ); + }); + describe('authorization', () => { test('ensures user is query filter types down to those the user is authorized to find', async () => { const filter = esKuery.fromKueryExpression( @@ -257,6 +586,7 @@ describe('find()', () => { "actions": Array [], "id": "1", "notifyWhen": undefined, + "params": undefined, "schedule": undefined, "tags": Array [ "myTag", diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts index 1be9d3e3ba2c92..82a8acefb386df 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/get.test.ts @@ -17,6 +17,7 @@ import { ActionsAuthorization } from '../../../../actions/server'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { RecoveredActionGroup } from '../../../common'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -118,6 +119,99 @@ describe('get()', () => { `); }); + test('should call useSavedObjectReferences.injectReferences if defined for rule type', async () => { + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + alertTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const alertsClient = new AlertsClient(alertsClientParams); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const result = await alertsClient.get({ id: '1' }); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "10s", + }, + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + test(`throws an error when references aren't found`, async () => { const alertsClient = new AlertsClient(alertsClientParams); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ @@ -146,6 +240,67 @@ describe('get()', () => { ); }); + test('throws an error if useSavedObjectReferences.injectReferences throws an error', async () => { + const injectReferencesFn = jest.fn().mockImplementation(() => { + throw new Error('something went wrong!'); + }); + alertTypeRegistry.get.mockImplementation(() => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: jest.fn(), + injectReferences: injectReferencesFn, + }, + })); + const alertsClient = new AlertsClient(alertsClientParams); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + await expect(alertsClient.get({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error injecting reference into rule params for rule id 1 - something went wrong!"` + ); + }); + describe('authorization', () => { beforeEach(() => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts index 2de56d20702f45..b65f3e06df9fcd 100644 --- a/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/tests/update.test.ts @@ -24,6 +24,12 @@ import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; +jest.mock('../../../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -401,6 +407,172 @@ describe('update()', () => { expect(actionsClient.isActionTypeEnabled).toHaveBeenCalledWith('test2', { notifyUsage: true }); }); + test('should call useSavedObjectReferences.extractReferences and useSavedObjectReferences.injectReferences if defined for rule type', async () => { + const ruleParams = { + bar: true, + parameterThatIsSavedObjectId: '9', + }; + const extractReferencesFn = jest.fn().mockReturnValue({ + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + references: [ + { + name: 'soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const injectReferencesFn = jest.fn().mockReturnValue({ + bar: true, + parameterThatIsSavedObjectId: '9', + }); + alertTypeRegistry.get.mockImplementation(() => ({ + id: 'myType', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + recoveryActionGroup: RecoveredActionGroup, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + async executor() {}, + producer: 'alerts', + useSavedObjectReferences: { + extractReferences: extractReferencesFn, + injectReferences: injectReferencesFn, + }, + })); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '10s' }, + params: { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + notifyWhen: 'onActiveAlert', + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + const result = await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: ruleParams, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + }, + }); + + expect(extractReferencesFn).toHaveBeenCalledWith(ruleParams); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { actionRef: 'action_0', actionTypeId: 'test', group: 'default', params: { foo: true } }, + ], + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + consumer: 'myApp', + enabled: true, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true, parameterThatIsSavedObjectRef: 'soRef_0' }, + schedule: { interval: '10s' }, + scheduledTaskId: 'task-123', + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: '1', + overwrite: true, + references: [ + { id: '1', name: 'action_0', type: 'action' }, + { id: '9', name: 'param:soRef_0', type: 'someSavedObjectType' }, + ], + version: '123', + } + ); + + expect(injectReferencesFn).toHaveBeenCalledWith( + { + bar: true, + parameterThatIsSavedObjectRef: 'soRef_0', + }, + [{ id: '9', name: 'soRef_0', type: 'someSavedObjectType' }] + ); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "createdAt": 2019-02-12T21:01:22.479Z, + "enabled": true, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + "parameterThatIsSavedObjectId": "9", + }, + "schedule": Object { + "interval": "10s", + }, + "scheduledTaskId": "task-123", + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + }); + it('calls the createApiKey function', async () => { alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, diff --git a/x-pack/plugins/alerting/server/config.test.ts b/x-pack/plugins/alerting/server/config.test.ts index f7280e05b78f31..a1ae77596ccbe3 100644 --- a/x-pack/plugins/alerting/server/config.test.ts +++ b/x-pack/plugins/alerting/server/config.test.ts @@ -19,6 +19,7 @@ describe('config validation', () => { "interval": "5m", "removalDelay": "1h", }, + "maxEphemeralActionsPerAlert": 10, } `); }); diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index e42955b385bf1e..47ef451ceab92c 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -8,6 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validateDurationSchema } from './lib'; +export const DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT = 10; export const configSchema = schema.object({ healthCheck: schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '60m' }), @@ -16,6 +17,9 @@ export const configSchema = schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), }), + maxEphemeralActionsPerAlert: schema.number({ + defaultValue: DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT, + }), }); export type AlertsConfig = TypeOf; diff --git a/x-pack/plugins/alerting/server/health/get_state.test.ts b/x-pack/plugins/alerting/server/health/get_state.test.ts index 24f3c101b26b6e..b58a1279418802 100644 --- a/x-pack/plugins/alerting/server/health/get_state.test.ts +++ b/x-pack/plugins/alerting/server/health/get_state.test.ts @@ -71,6 +71,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }), pollInterval ).subscribe(); @@ -104,6 +105,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }), pollInterval, retryDelay @@ -148,6 +150,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }) ).toPromise(); @@ -178,6 +181,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }) ).toPromise(); @@ -208,6 +212,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }) ).toPromise(); @@ -235,6 +240,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }), retryDelay ).subscribe((status) => { @@ -265,6 +271,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }), retryDelay ).subscribe((status) => { @@ -301,6 +308,7 @@ describe('getHealthServiceStatusWithRetryAndErrorHandling', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }) ).toPromise(); diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 957bd89f52f36c..d4cc47d221906e 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -28,7 +28,9 @@ export type { AlertInstanceState, AlertInstanceContext, AlertingApiRequestHandlerContext, + RuleParamsAndRefs, } from './types'; +export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config'; export { PluginSetupContract, PluginStartContract } from './plugin'; export { FindResult } from './alerts_client'; export { PublicAlertInstance as AlertInstance } from './alert_instance'; diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index e04ce85b353744..6cfe3682458427 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -57,7 +57,7 @@ describe('getLicenseCheckForAlertType', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ @@ -192,7 +192,7 @@ describe('ensureLicenseForAlertType()', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const alertType: AlertType = { + const alertType: AlertType = { id: 'test', name: 'Test', actionGroups: [ diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index dc5d9d278b6b5f..837fecde11659c 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -140,6 +140,7 @@ export class LicenseState { public ensureLicenseForAlertType< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -148,6 +149,7 @@ export class LicenseState { >( alertType: AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 9adc3cc9d65692..b1fc44ab4122ff 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -36,6 +36,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 10, }); plugin = new AlertingPlugin(context); @@ -61,7 +62,7 @@ describe('Alerting Plugin', () => { describe('registerType()', () => { let setup: PluginSetupContract; - const sampleAlertType: AlertType = { + const sampleAlertType: AlertType = { id: 'test', name: 'test', minimumLicenseRequired: 'basic', @@ -122,6 +123,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 10, }); const plugin = new AlertingPlugin(context); @@ -161,6 +163,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 10, }); const plugin = new AlertingPlugin(context); @@ -214,6 +217,7 @@ describe('Alerting Plugin', () => { interval: '5m', removalDelay: '1h', }, + maxEphemeralActionsPerAlert: 100, }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index b906983017ff60..096182dc9c3c80 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -87,6 +87,7 @@ export const LEGACY_EVENT_LOG_ACTIONS = { export interface PluginSetupContract { registerType< Params extends AlertTypeParams = AlertTypeParams, + ExtractedParams extends AlertTypeParams = AlertTypeParams, State extends AlertTypeState = AlertTypeState, InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, @@ -95,6 +96,7 @@ export interface PluginSetupContract { >( alertType: AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -277,6 +279,7 @@ export class AlertingPlugin { return { registerType< Params extends AlertTypeParams = AlertTypeParams, + ExtractedParams extends AlertTypeParams = AlertTypeParams, State extends AlertTypeState = AlertTypeState, InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, @@ -285,6 +288,7 @@ export class AlertingPlugin { >( alertType: AlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -376,6 +380,8 @@ export class AlertingPlugin { internalSavedObjectsRepository: core.savedObjects.createInternalRepository(['alert']), alertTypeRegistry: this.alertTypeRegistry!, kibanaBaseUrl: this.kibanaBaseUrl, + supportsEphemeralTasks: plugins.taskManager.supportsEphemeralTasks(), + maxEphemeralActionsPerAlert: this.config.then((config) => config.maxEphemeralActionsPerAlert), }); this.eventLogService!.registerSavedObjectProvider('alert', (request) => { diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index b264428b4d6f2b..a40f7dc2abc720 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -29,6 +29,7 @@ jest.mock('./inject_action_params', () => ({ })); const alertType: NormalizedAlertType< + AlertTypeParams, AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -59,6 +60,7 @@ const mockActionsPlugin = actionsMock.createStart(); const mockEventLogger = eventLoggerMock.create(); const createExecutionHandlerParams: jest.Mocked< CreateExecutionHandlerOptions< + AlertTypeParams, AlertTypeParams, AlertTypeState, AlertInstanceState, @@ -96,6 +98,8 @@ const createExecutionHandlerParams: jest.Mocked< contextVal: 'My other {{context.value}} goes here', stateVal: 'My other {{state.value}} goes here', }, + supportsEphemeralTasks: false, + maxEphemeralActionsPerAlert: Promise.resolve(10), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index 3004ed599128e5..808e9f5de8f421 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -4,12 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { transformActionParams } from './transform_action_params'; import { - PluginStartContract as ActionsPluginStartContract, asSavedObjectExecutionSource, + PluginStartContract as ActionsPluginStartContract, } from '../../../actions/server'; import { IEventLogger, IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -23,9 +22,11 @@ import { RawAlert, } from '../types'; import { NormalizedAlertType } from '../alert_type_registry'; +import { isEphemeralTaskRejectedDueToCapacityError } from '../../../task_manager/server'; export interface CreateExecutionHandlerOptions< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -42,6 +43,7 @@ export interface CreateExecutionHandlerOptions< kibanaBaseUrl: string | undefined; alertType: NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -52,6 +54,8 @@ export interface CreateExecutionHandlerOptions< eventLogger: IEventLogger; request: KibanaRequest; alertParams: AlertTypeParams; + supportsEphemeralTasks: boolean; + maxEphemeralActionsPerAlert: Promise; } interface ExecutionHandlerOptions { @@ -68,6 +72,7 @@ export type ExecutionHandler = ( export function createExecutionHandler< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -87,8 +92,11 @@ export function createExecutionHandler< eventLogger, request, alertParams, + supportsEphemeralTasks, + maxEphemeralActionsPerAlert, }: CreateExecutionHandlerOptions< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -147,6 +155,8 @@ export function createExecutionHandler< const alertLabel = `${alertType.id}:${alertId}: '${alertName}'`; + const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); + let ephemeralActionsToSchedule = await maxEphemeralActionsPerAlert; for (const action of actions) { if ( !actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true }) @@ -159,10 +169,7 @@ export function createExecutionHandler< const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - // TODO would be nice to add the action name here, but it's not available - const actionLabel = `${action.actionTypeId}:${action.id}`; - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); - await actionsClient.enqueueExecution({ + const enqueueOptions = { id: action.id, params: action.params, spaceId, @@ -179,7 +186,20 @@ export function createExecutionHandler< typeId: alertType.id, }, ], - }); + }; + + // TODO would be nice to add the action name here, but it's not available + const actionLabel = `${action.actionTypeId}:${action.id}`; + if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { + ephemeralActionsToSchedule--; + actionsClient.ephemeralEnqueuedExecution(enqueueOptions).catch(async (err) => { + if (isEphemeralTaskRejectedDueToCapacityError(err)) { + await actionsClient.enqueueExecution(enqueueOptions); + } + }); + } else { + await actionsClient.enqueueExecution(enqueueOptions); + } const event: IEvent = { event: { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 4f650975f830ee..62ca000bc83654 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -17,6 +17,7 @@ import { import { ConcreteTaskInstance, isUnrecoverableError, + RunNowResult, TaskStatus, } from '../../../task_manager/server'; import { TaskRunnerContext } from './task_runner_factory'; @@ -37,6 +38,7 @@ import { Alert, RecoveredActionGroup } from '../../common'; import { omit } from 'lodash'; import { UntypedNormalizedAlertType } from '../alert_type_registry'; import { alertTypeRegistryMock } from '../alert_type_registry.mock'; +import { ExecuteOptions } from '../../../actions/server/create_execute_function'; const alertType: jest.Mocked = { id: 'test', @@ -84,10 +86,12 @@ describe('Task Runner', () => { const alertsClient = alertsClientMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); - const taskRunnerFactoryInitializerParams: jest.Mocked & { + type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; eventLogger: jest.Mocked; - } = { + }; + + const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { getServices: jest.fn().mockReturnValue(services), actionsPlugin: actionsMock.createStart(), getAlertsClientWithRequest: jest.fn().mockReturnValue(alertsClient), @@ -99,8 +103,30 @@ describe('Task Runner', () => { internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), alertTypeRegistry, kibanaBaseUrl: 'https://localhost:5601', + supportsEphemeralTasks: false, + maxEphemeralActionsPerAlert: new Promise((resolve) => resolve(10)), }; + function testAgainstEphemeralSupport( + name: string, + fn: ( + params: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => jest.ProvidesCallback + ) { + test(name, fn(taskRunnerFactoryInitializerParams, actionsClient.enqueueExecution)); + test( + `${name} (with ephemeral support)`, + fn( + { + ...taskRunnerFactoryInitializerParams, + supportsEphemeralTasks: true, + }, + actionsClient.ephemeralEnqueuedExecution + ) + ); + } + const mockDate = new Date('2019-02-12T21:01:22.479Z'); const mockedAlertTypeSavedObject: Alert = { @@ -314,41 +340,51 @@ describe('Task Runner', () => { ); }); - test('actionsPlugin.execute is called per alert instance that is scheduled', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices - .alertInstanceFactory('1') - .scheduleActionsWithSubGroup('default', 'subDefault'); - } - ); - const taskRunner = new TaskRunner( - alertType, - mockedTaskInstance, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + testAgainstEphemeralSupport( + 'actionsPlugin.execute is called per alert instance that is scheduled', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices + .alertInstanceFactory('1') + .scheduleActionsWithSubGroup('default', 'subDefault'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(enqueueFunction).toHaveBeenCalledTimes(1); + expect((enqueueFunction as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -376,179 +412,181 @@ describe('Task Runner', () => { ] `); - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(3); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 3, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); + const logger = customTaskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(3); + expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith( + 2, + `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + ); + expect(logger.debug).nthCalledWith( + 3, + 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + ); + // alertExecutionStatus for test:1: {\"lastExecutionDate\":\"1970-01-01T00:00:00.000Z\",\"status\":\"error\",\"error\":{\"reason\":\"unknown\",\"message\":\"Cannot read property 'catch' of undefined\"}} - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - '@timestamp': '1970-01-01T00:00:00.000Z', - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', + const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { + action: 'execute-start', + category: ['alerts'], + kind: 'alert', }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', + kibana: { + task: { + schedule_delay: 0, + scheduled: '1970-01-01T00:00:00.000Z', }, - ], - }, - message: `alert execution start: "1"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'new-instance', - category: ['alerts'], - kind: 'alert', - duration: 0, - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alerting: { - action_group_id: 'default', - action_subgroup: 'subDefault', - instance_id: '1', + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + ], }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - }, - message: "test:1: 'alert-name' created new instance: '1'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'alert-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'active-instance', - category: ['alerts'], - duration: 0, - kind: 'alert', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alerting: { action_group_id: 'default', action_subgroup: 'subDefault', instance_id: '1' }, - saved_objects: [ - { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, - ], - }, - message: - "test:1: 'alert-name' active instance: '1' in actionGroup(subgroup): 'default(subDefault)'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'alert-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { - event: { - action: 'execute-action', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alerting: { - instance_id: '1', - action_group_id: 'default', - action_subgroup: 'subDefault', + message: `alert execution start: "1"`, + rule: { + category: 'test', + id: '1', + license: 'basic', + ruleset: 'alerts', }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', + }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { + event: { + action: 'new-instance', + category: ['alerts'], + kind: 'alert', + duration: 0, + start: '1970-01-01T00:00:00.000Z', + }, + kibana: { + alerting: { + action_group_id: 'default', + action_subgroup: 'subDefault', + instance_id: '1', }, - { - id: '1', - namespace: undefined, - type: 'action', - type_id: 'action', + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + ], + }, + message: "test:1: 'alert-name' created new instance: '1'", + rule: { + category: 'test', + id: '1', + license: 'basic', + name: 'alert-name', + namespace: undefined, + ruleset: 'alerts', + }, + }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { + event: { + action: 'active-instance', + category: ['alerts'], + duration: 0, + kind: 'alert', + start: '1970-01-01T00:00:00.000Z', + }, + kibana: { + alerting: { action_group_id: 'default', action_subgroup: 'subDefault', instance_id: '1' }, + saved_objects: [ + { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, + ], + }, + message: + "test:1: 'alert-name' active instance: '1' in actionGroup(subgroup): 'default(subDefault)'", + rule: { + category: 'test', + id: '1', + license: 'basic', + name: 'alert-name', + namespace: undefined, + ruleset: 'alerts', + }, + }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { + event: { + action: 'execute-action', + category: ['alerts'], + kind: 'alert', + }, + kibana: { + alerting: { + instance_id: '1', + action_group_id: 'default', + action_subgroup: 'subDefault', }, - ], - }, - message: - "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup(subgroup): 'default(subDefault)' action: action:1", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'alert-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { - '@timestamp': '1970-01-01T00:00:00.000Z', - event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, - kibana: { - alerting: { - status: 'active', + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + { + id: '1', + namespace: undefined, + type: 'action', + type_id: 'action', + }, + ], }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', + message: + "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup(subgroup): 'default(subDefault)' action: action:1", + rule: { + category: 'test', + id: '1', + license: 'basic', + name: 'alert-name', + namespace: undefined, + ruleset: 'alerts', }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', + }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, + kibana: { + alerting: { + status: 'active', }, - ], - }, - message: "alert executed: test:1: 'alert-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'alert-name', - ruleset: 'alerts', - }, - }); - }); + task: { + schedule_delay: 0, + scheduled: '1970-01-01T00:00:00.000Z', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + ], + }, + message: "alert executed: test:1: 'alert-name'", + rule: { + category: 'test', + id: '1', + license: 'basic', + name: 'alert-name', + ruleset: 'alerts', + }, + }); + } + ); test('actionsPlugin.execute is skipped if muteAll is true', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); @@ -584,7 +622,7 @@ describe('Task Runner', () => { references: [], }); await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); + expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(4); @@ -738,59 +776,70 @@ describe('Task Runner', () => { }); }); - test('skips firing actions for active instance if instance is muted', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - executorServices.alertInstanceFactory('2').scheduleActions('default'); - } - ); - const taskRunner = new TaskRunner( - alertType, - mockedTaskInstance, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, - mutedInstanceIds: ['2'], - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + testAgainstEphemeralSupport( + 'skips firing actions for active instance if instance is muted', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + executorServices.alertInstanceFactory('2').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + mutedInstanceIds: ['2'], + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(enqueueFunction).toHaveBeenCalledTimes(1); - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(4); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `alert test:1: 'alert-name' has 2 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"},{\"instanceId\":\"2\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 3, - `skipping scheduling of actions for '2' in alert test:1: 'alert-name': instance is muted` - ); - expect(logger.debug).nthCalledWith( - 4, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); - }); + const logger = customTaskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(4); + expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith( + 2, + `alert test:1: 'alert-name' has 2 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"},{\"instanceId\":\"2\",\"actionGroup\":\"default\"}]` + ); + expect(logger.debug).nthCalledWith( + 3, + `skipping scheduling of actions for '2' in alert test:1: 'alert-name': instance is muted` + ); + expect(logger.debug).nthCalledWith( + 4, + 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + ); + } + ); test('actionsPlugin.execute is not called when notifyWhen=onActionGroupChange and alert instance state does not change', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); @@ -843,7 +892,7 @@ describe('Task Runner', () => { references: [], }); await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(0); + expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); @@ -949,177 +998,210 @@ describe('Task Runner', () => { "scheduled": "1970-01-01T00:00:00.000Z", }, }, - "message": "alert executed: test:1: 'alert-name'", - "rule": Object { - "category": "test", - "id": "1", - "license": "basic", - "name": "alert-name", - "ruleset": "alerts", - }, - }, - ], - ] - `); - }); - - test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - } - ); - const taskRunner = new TaskRunner( - alertType, - { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { - meta: { lastScheduledActions: { group: 'newGroup', date: new Date().toISOString() } }, - state: { bar: false }, + "message": "alert executed: test:1: 'alert-name'", + "rule": Object { + "category": "test", + "id": "1", + "license": "basic", + "name": "alert-name", + "ruleset": "alerts", }, }, - }, - }, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, - notifyWhen: 'onActionGroupChange', - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); + ], + ] + `); }); - test('actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices - .alertInstanceFactory('1') - .scheduleActionsWithSubGroup('default', 'subgroup1'); - } - ); - const taskRunner = new TaskRunner( - alertType, - { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { - meta: { - lastScheduledActions: { - group: 'default', - subgroup: 'newSubgroup', - date: new Date().toISOString(), + testAgainstEphemeralSupport( + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state has changed', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { group: 'newGroup', date: new Date().toISOString() }, }, + state: { bar: false }, }, - state: { bar: false }, }, }, }, - }, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, - notifyWhen: 'onActionGroupChange', - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); - }); + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(enqueueFunction).toHaveBeenCalledTimes(1); + } + ); - test('includes the apiKey in the request used to initialize the actionsClient', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - } - ); - const taskRunner = new TaskRunner( - alertType, - mockedTaskInstance, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect( - taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest - ).toHaveBeenCalledWith( - expect.objectContaining({ - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', + testAgainstEphemeralSupport( + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert instance state subgroup has changed', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices + .alertInstanceFactory('1') + .scheduleActionsWithSubGroup('default', 'subgroup1'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + group: 'default', + subgroup: 'newSubgroup', + date: new Date().toISOString(), + }, + }, + state: { bar: false }, + }, + }, + }, }, - }) - ); + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + notifyWhen: 'onActionGroupChange', + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(enqueueFunction).toHaveBeenCalledTimes(1); + } + ); - const [ - request, - ] = taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mock.calls[0]; + testAgainstEphemeralSupport( + 'includes the apiKey in the request used to initialize the actionsClient', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect( + customTaskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest + ).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + }) + ); - expect(taskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( - request, - '/' - ); + const [ + request, + ] = customTaskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mock.calls[0]; + + expect(customTaskRunnerFactoryInitializerParams.basePathService.set).toHaveBeenCalledWith( + request, + '/' + ); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + expect(enqueueFunction).toHaveBeenCalledTimes(1); + expect((enqueueFunction as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -1147,10 +1229,10 @@ describe('Task Runner', () => { ] `); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ Object { @@ -1340,64 +1422,75 @@ describe('Task Runner', () => { ], ] `); - }); + } + ); - test('fire recovered actions for execution for the alertInstances which is in the recovered state', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + testAgainstEphemeralSupport( + 'fire recovered actions for execution for the alertInstances which is in the recovered state', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - } - ); - const taskRunner = new TaskRunner( - alertType, - { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { - meta: {}, - state: { - bar: false, - start: '1969-12-31T00:00:00.000Z', - duration: 80000000000, + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { + meta: {}, + state: { + bar: false, + start: '1969-12-31T00:00:00.000Z', + duration: 80000000000, + }, }, - }, - '2': { - meta: {}, - state: { - bar: false, - start: '1969-12-31T06:00:00.000Z', - duration: 70000000000, + '2': { + meta: {}, + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: 70000000000, + }, }, }, }, }, - }, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` Object { "1": Object { "meta": Object { @@ -1416,26 +1509,26 @@ describe('Task Runner', () => { } `); - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(4); - expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 3, - `alert test:1: 'alert-name' has 1 recovered alert instances: [\"2\"]` - ); - expect(logger.debug).nthCalledWith( - 4, - 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); + const logger = customTaskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(4); + expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith( + 2, + `alert test:1: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + ); + expect(logger.debug).nthCalledWith( + 3, + `alert test:1: 'alert-name' has 1 recovered alert instances: [\"2\"]` + ); + expect(logger.debug).nthCalledWith( + 4, + 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' + ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ Object { @@ -1667,8 +1760,8 @@ describe('Task Runner', () => { ] `); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); - expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + expect(enqueueFunction).toHaveBeenCalledTimes(2); + expect((enqueueFunction as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -1692,60 +1785,71 @@ describe('Task Runner', () => { "type": "SAVED_OBJECT", }, "spaceId": undefined, - }, - ] - `); - }); - - test('should skip alertInstances which werent active on the previous execution', async () => { - const alertId = 'e558aaad-fd81-46d2-96fc-3bd8fc3dc03f'; - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); - - alertType.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - - // create an instance, but don't schedule any actions, so it doesn't go active - executorServices.alertInstanceFactory('3'); - } - ); - const taskRunner = new TaskRunner( - alertType, - { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, + }, + ] + `); + } + ); + + testAgainstEphemeralSupport( + 'should skip alertInstances which werent active on the previous execution', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + const alertId = 'e558aaad-fd81-46d2-96fc-3bd8fc3dc03f'; + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); + + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + + // create an instance, but don't schedule any actions, so it doesn't go active + executorServices.alertInstanceFactory('3'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + params: { + alertId, }, }, - params: { - alertId, + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: alertId, + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), }, - }, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: alertId, - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` Object { "1": Object { "meta": Object { @@ -1762,93 +1866,111 @@ describe('Task Runner', () => { } `); - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledWith( - `alert test:${alertId}: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).toHaveBeenCalledWith( - `alert test:${alertId}: 'alert-name' has 1 recovered alert instances: [\"2\"]` - ); + const logger = customTaskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledWith( + `alert test:${alertId}: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); - expect(actionsClient.enqueueExecution.mock.calls[1][0].id).toEqual('1'); - expect(actionsClient.enqueueExecution.mock.calls[0][0].id).toEqual('2'); - }); + expect(logger.debug).nthCalledWith( + 3, + `alert test:${alertId}: 'alert-name' has 1 recovered alert instances: [\"2\"]` + ); + expect(logger.debug).nthCalledWith( + 4, + `alertExecutionStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + ); - test('fire actions under a custom recovery group when specified on an alert type for alertInstances which are in the recovered state', async () => { - taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); - taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + expect(enqueueFunction).toHaveBeenCalledTimes(2); + expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('1'); + expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('2'); + } + ); - const recoveryActionGroup = { - id: 'customRecovered', - name: 'Custom Recovered', - }; - const alertTypeWithCustomRecovery = { - ...alertType, - recoveryActionGroup, - actionGroups: [{ id: 'default', name: 'Default' }, recoveryActionGroup], - }; + testAgainstEphemeralSupport( + 'fire actions under a custom recovery group when specified on an alert type for alertInstances which are in the recovered state', + ( + customTaskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType, + enqueueFunction: (options: ExecuteOptions) => Promise + ) => async () => { + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( + true + ); + customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue( + true + ); - alertTypeWithCustomRecovery.executor.mockImplementation( - async ({ - services: executorServices, - }: AlertExecutorOptions< - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - string - >) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - } - ); - const taskRunner = new TaskRunner( - alertTypeWithCustomRecovery, - { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, - }, - }, - }, - taskRunnerFactoryInitializerParams - ); - alertsClient.get.mockResolvedValue({ - ...mockedAlertTypeSavedObject, - actions: [ + actionsClient.ephemeralEnqueuedExecution.mockResolvedValue(new Promise(() => {})); + + const recoveryActionGroup = { + id: 'customRecovered', + name: 'Custom Recovered', + }; + const alertTypeWithCustomRecovery = { + ...alertType, + recoveryActionGroup, + actionGroups: [{ id: 'default', name: 'Default' }, recoveryActionGroup], + }; + + alertTypeWithCustomRecovery.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertTypeWithCustomRecovery, { - group: 'default', - id: '1', - actionTypeId: 'action', - params: { - foo: true, + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, }, }, - { - group: recoveryActionGroup.id, - id: '2', - actionTypeId: 'action', - params: { - isResolved: true, + customTaskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue({ + ...mockedAlertTypeSavedObject, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: 'action', + params: { + foo: true, + }, }, + { + group: recoveryActionGroup.id, + id: '2', + actionTypeId: 'action', + params: { + isResolved: true, + }, + }, + ], + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), }, - ], - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` Object { "1": Object { "meta": Object { @@ -1865,10 +1987,10 @@ describe('Task Runner', () => { } `); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); - expect(actionsClient.enqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + expect(enqueueFunction).toHaveBeenCalledTimes(2); + expect((enqueueFunction as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { "apiKey": "MTIzOmFiYw==", @@ -1895,7 +2017,8 @@ describe('Task Runner', () => { }, ] `); - }); + } + ); test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { alertType.executor.mockImplementation( @@ -4081,4 +4204,160 @@ describe('Task Runner', () => { ] `); }); + + test('successfully executes the task with ephemeral tasks enabled', async () => { + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }, + }, + { + ...taskRunnerFactoryInitializerParams, + supportsEphemeralTasks: true, + } + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "schedule": Object { + "interval": "10s", + }, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + expect(alertType.executor).toHaveBeenCalledTimes(1); + const call = alertType.executor.mock.calls[0][0]; + expect(call.params).toMatchInlineSnapshot(` + Object { + "bar": true, + } + `); + expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); + expect(call.previousStartedAt).toMatchInlineSnapshot(`1969-12-31T23:55:00.000Z`); + expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.name).toBe('alert-name'); + expect(call.tags).toEqual(['alert-', '-tags']); + expect(call.createdBy).toBe('alert-creator'); + expect(call.updatedBy).toBe('alert-updater'); + expect(call.rule).not.toBe(null); + expect(call.rule.name).toBe('alert-name'); + expect(call.rule.tags).toEqual(['alert-', '-tags']); + expect(call.rule.consumer).toBe('bar'); + expect(call.rule.enabled).toBe(true); + expect(call.rule.schedule).toMatchInlineSnapshot(` + Object { + "interval": "10s", + } + `); + expect(call.rule.createdBy).toBe('alert-creator'); + expect(call.rule.updatedBy).toBe('alert-updater'); + expect(call.rule.createdAt).toBe(mockDate); + expect(call.rule.updatedAt).toBe(mockDate); + expect(call.rule.notifyWhen).toBe('onActiveAlert'); + expect(call.rule.throttle).toBe(null); + expect(call.rule.producer).toBe('alerts'); + expect(call.rule.ruleTypeId).toBe('test'); + expect(call.rule.ruleTypeName).toBe('My test alert'); + expect(call.rule.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "action", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + Object { + "actionTypeId": "action", + "group": "recovered", + "id": "2", + "params": Object { + "isResolved": true, + }, + }, + ] + `); + expect(call.services.alertInstanceFactory).toBeTruthy(); + expect(call.services.scopedClusterClient).toBeTruthy(); + expect(call.services).toBeTruthy(); + + const logger = taskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).nthCalledWith(1, 'executing alert test:1 at 1970-01-01T00:00:00.000Z'); + expect(logger.debug).nthCalledWith( + 2, + 'alertExecutionStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"ok"}' + ); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "event": Object { + "action": "execute-start", + "category": Array [ + "alerts", + ], + "kind": "alert", + }, + "kibana": Object { + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + "type_id": "test", + }, + ], + "task": Object { + "schedule_delay": 0, + "scheduled": "1970-01-01T00:00:00.000Z", + }, + }, + "message": "alert execution start: \\"1\\"", + "rule": Object { + "category": "test", + "id": "1", + "license": "basic", + "ruleset": "alerts", + }, + } + `); + + expect( + taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update + ).toHaveBeenCalledWith( + 'alert', + '1', + { + executionStatus: { + error: null, + lastExecutionDate: '1970-01-01T00:00:00.000Z', + status: 'ok', + }, + }, + { refresh: false, namespace: undefined } + ); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index c66c054bc8ac3a..605588cbf321ff 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -70,6 +70,7 @@ interface AlertTaskInstance extends ConcreteTaskInstance { export class TaskRunner< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -81,6 +82,7 @@ export class TaskRunner< private taskInstance: AlertTaskInstance; private alertType: NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -92,6 +94,7 @@ export class TaskRunner< constructor( alertType: NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -171,6 +174,7 @@ export class TaskRunner< ) { return createExecutionHandler< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -190,6 +194,8 @@ export class TaskRunner< eventLogger: this.context.eventLogger, request: this.getFakeKibanaRequest(spaceId, apiKey), alertParams, + supportsEphemeralTasks: this.context.supportsEphemeralTasks, + maxEphemeralActionsPerAlert: this.context.maxEphemeralActionsPerAlert, }); } @@ -701,6 +707,7 @@ interface GenerateNewAndRecoveredInstanceEventsParams< alertLabel: string; namespace: string | undefined; ruleType: NormalizedAlertType< + AlertTypeParams, AlertTypeParams, AlertTypeState, { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 050345f3e617f4..a284fc25c6253a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -79,6 +79,8 @@ describe('Task Runner Factory', () => { internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), alertTypeRegistry: alertTypeRegistryMock.create(), kibanaBaseUrl: 'https://localhost:5601', + supportsEphemeralTasks: true, + maxEphemeralActionsPerAlert: new Promise((resolve) => resolve(10)), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index a023776134e9cf..698cb935874b25 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -41,6 +41,8 @@ export interface TaskRunnerContext { internalSavedObjectsRepository: ISavedObjectsRepository; alertTypeRegistry: AlertTypeRegistry; kibanaBaseUrl: string | undefined; + supportsEphemeralTasks: boolean; + maxEphemeralActionsPerAlert: Promise; } export class TaskRunnerFactory { @@ -57,6 +59,7 @@ export class TaskRunnerFactory { public create< Params extends AlertTypeParams, + ExtractedParams extends AlertTypeParams, State extends AlertTypeState, InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, @@ -65,6 +68,7 @@ export class TaskRunnerFactory { >( alertType: NormalizedAlertType< Params, + ExtractedParams, State, InstanceState, InstanceContext, @@ -79,6 +83,7 @@ export class TaskRunnerFactory { return new TaskRunner< Params, + ExtractedParams, State, InstanceState, InstanceContext, diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f21e17adc841d8..b12341a5a602da 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, RequestHandlerContext } from 'src/core/server'; +import type { IRouter, RequestHandlerContext, SavedObjectReference } from 'src/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { PublicAlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; @@ -99,6 +99,11 @@ export interface AlertExecutorOptions< updatedBy: string | null; } +export interface RuleParamsAndRefs { + references: SavedObjectReference[]; + params: Params; +} + export type ExecutorType< Params extends AlertTypeParams = never, State extends AlertTypeState = never, @@ -114,6 +119,7 @@ export interface AlertTypeParamsValidator { } export interface AlertType< Params extends AlertTypeParams = never, + ExtractedParams extends AlertTypeParams = never, State extends AlertTypeState = never, InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, @@ -146,6 +152,10 @@ export interface AlertType< params?: ActionVariable[]; }; minimumLicenseRequired: LicenseType; + useSavedObjectReferences?: { + extractReferences: (params: Params) => RuleParamsAndRefs; + injectReferences: (params: ExtractedParams, references: SavedObjectReference[]) => Params; + }; isExportable: boolean; } diff --git a/x-pack/plugins/apm/common/utils/get_offset_in_ms.ts b/x-pack/plugins/apm/common/utils/get_offset_in_ms.ts new file mode 100644 index 00000000000000..ac662e211a6a12 --- /dev/null +++ b/x-pack/plugins/apm/common/utils/get_offset_in_ms.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import moment from 'moment'; +import { parseInterval } from '../../../../../src/plugins/data/common'; + +export function getOffsetInMs(start: number, offset?: string) { + if (!offset) { + return 0; + } + + const interval = parseInterval(offset); + + if (!interval) { + throw new Error(`Could not parse offset: ${offset}`); + } + + const calculatedOffset = start - moment(start).subtract(interval).valueOf(); + + return calculatedOffset; +} diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx index 16b8cc34e97527..d8bae2a3db636d 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { Story } from '@storybook/react'; import { cloneDeep, merge } from 'lodash'; import React, { ComponentType } from 'react'; import { MemoryRouter, Route } from 'react-router-dom'; @@ -14,13 +15,14 @@ import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; +import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; export default { title: 'alerting/TransactionDurationAlertTrigger', component: TransactionDurationAlertTrigger, decorators: [ - (Story: ComponentType) => { + (StoryComponent: ComponentType) => { const contextMock = (merge(cloneDeep(mockApmPluginContextValue), { core: { http: { @@ -39,11 +41,13 @@ export default { return (
- - + + - + + + @@ -54,7 +58,7 @@ export default { ], }; -export function Example() { +export const Example: Story = () => { const params = { threshold: 1500, aggregationType: 'avg' as const, @@ -67,4 +71,4 @@ export function Example() { setAlertProperty={() => undefined} /> ); -} +}; diff --git a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx index 9593802407193d..f49f27d94a0856 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/customize_ui/custom_link/custom_link_table.tsx @@ -49,7 +49,7 @@ export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) { truncateText: true, }, { - width: 160, + width: '160px', align: 'right', field: '@timestamp', name: i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx index b22260ffabe463..67eae5376a74ea 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx @@ -12,6 +12,7 @@ import { CoreStart } from '../../../../../../../../src/core/public'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { Schema } from './'; +import { ConfirmSwitchModal } from './confirm_switch_modal'; interface Args { hasCloudAgentPolicy: boolean; @@ -107,3 +108,18 @@ export default { export const Example: Story = () => { return ; }; + +interface ModalArgs { + unsupportedConfigs: Array<{ key: string; value: string }>; +} + +export const Modal: Story = ({ unsupportedConfigs }) => { + return ( + {}} + onConfirm={() => {}} + unsupportedConfigs={unsupportedConfigs} + /> + ); +}; +Modal.args = { unsupportedConfigs: [{ key: 'test', value: '123' }] }; diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx new file mode 100644 index 00000000000000..b1e58a089c8cdc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { getDurationFormatter } from '../../../../common/utils/formatters'; +import { useApmBackendContext } from '../../../context/apm_backend/use_apm_backend_context'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { useComparison } from '../../../hooks/use_comparison'; +import { useFetcher } from '../../../hooks/use_fetcher'; +import { useTimeRange } from '../../../hooks/use_time_range'; +import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; +import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; +import { useTheme } from '../../../hooks/use_theme'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../../shared/charts/transaction_charts/helper'; + +export function BackendLatencyChart({ height }: { height: number }) { + const { backendName } = useApmBackendContext(); + + const theme = useTheme(); + + const { start, end } = useTimeRange(); + + const { + urlParams: { kuery, environment }, + } = useUrlParams(); + + const { offset, comparisonChartTheme } = useComparison(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/backends/{backendName}/charts/latency', + params: { + path: { + backendName, + }, + query: { + start, + end, + offset, + kuery, + environment, + }, + }, + }); + }, + [backendName, start, end, offset, kuery, environment] + ); + + const timeseries = useMemo(() => { + const specs: Array> = []; + + if (data?.currentTimeseries) { + specs.push({ + data: data.currentTimeseries, + type: 'linemark', + color: theme.eui.euiColorVis0, + title: i18n.translate('xpack.apm.backendLatencyChart.chartTitle', { + defaultMessage: 'Latency', + }), + }); + } + + if (data?.comparisonTimeseries) { + specs.push({ + data: data.comparisonTimeseries, + type: 'area', + color: theme.eui.euiColorMediumShade, + title: i18n.translate( + 'xpack.apm.backendLatencyChart.previousPeriodLabel', + { defaultMessage: 'Previous period' } + ), + }); + } + + return specs; + }, [data, theme.eui.euiColorVis0, theme.eui.euiColorMediumShade]); + + const maxY = getMaxY(timeseries); + const latencyFormatter = getDurationFormatter(maxY); + + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx new file mode 100644 index 00000000000000..6a5725c9d88fd7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiFlexItem } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import React from 'react'; +import { ApmBackendContextProvider } from '../../../context/apm_backend/apm_backend_context'; +import { useBreadcrumb } from '../../../context/breadcrumbs/use_breadcrumb'; +import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { useApmParams } from '../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../hooks/use_apm_router'; +import { ApmMainTemplate } from '../../routing/templates/apm_main_template'; +import { SearchBar } from '../../shared/search_bar'; +import { BackendLatencyChart } from './backend_latency_chart'; +import { BackendInventoryTitle } from '../../routing/home'; + +export function BackendDetailOverview() { + const { + path: { backendName }, + query, + } = useApmParams('/backends/:backendName/overview'); + + const apmRouter = useApmRouter(); + + useBreadcrumb([ + { + title: BackendInventoryTitle, + href: apmRouter.link('/backends'), + }, + { + title: backendName, + href: apmRouter.link('/backends/:backendName/overview', { + path: { backendName }, + query, + }), + }, + ]); + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/security_solution/server/lib/configuration/adapter_types.ts b/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx similarity index 61% rename from x-pack/plugins/security_solution/server/lib/configuration/adapter_types.ts rename to x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx index d962cacbb67129..115f4a56d360fa 100644 --- a/x-pack/plugins/security_solution/server/lib/configuration/adapter_types.ts +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/index.tsx @@ -5,6 +5,9 @@ * 2.0. */ -export interface ConfigurationAdapter { - get(): Promise; +import React from 'react'; +import { SearchBar } from '../../shared/search_bar'; + +export function BackendInventory() { + return ; } diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx index 4d7457422e83bd..6e181535cc05c0 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/index.tsx @@ -79,7 +79,7 @@ function ErrorGroupList({ items, serviceName }: Props) { ), field: 'groupId', sortable: false, - width: unit * 6, + width: `${unit * 6}px`, render: (groupId: string) => { return ( diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx index a3b0ec0ac66de0..793ca6f0655dfc 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_list/index.tsx @@ -84,7 +84,7 @@ export function getServiceColumns({ name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { defaultMessage: 'Health', }), - width: unit * 6, + width: `${unit * 6}px`, sortable: true, render: (_, { healthStatus }) => { return ( @@ -135,7 +135,7 @@ export function getServiceColumns({ name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', { defaultMessage: 'Environment', }), - width: unit * 10, + width: `${unit * 10}px`, sortable: true, render: (_, { environments }) => ( @@ -149,7 +149,7 @@ export function getServiceColumns({ 'xpack.apm.servicesTable.transactionColumnLabel', { defaultMessage: 'Transaction type' } ), - width: unit * 10, + width: `${unit * 10}px`, sortable: true, }, ] @@ -169,7 +169,7 @@ export function getServiceColumns({ /> ), align: 'left', - width: unit * 10, + width: `${unit * 10}px`, }, { field: 'transactionsPerMinute', @@ -186,7 +186,7 @@ export function getServiceColumns({ /> ), align: 'left', - width: unit * 10, + width: `${unit * 10}px`, }, { field: 'transactionErrorRate', @@ -209,7 +209,7 @@ export function getServiceColumns({ ); }, align: 'left', - width: unit * 10, + width: `${unit * 10}px`, }, ]; } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index 19318553727cb7..c348b3f13104ad 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -32,9 +32,10 @@ import { fromQuery } from '../../shared/Links/url_helpers'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import { uiSettingsServiceMock } from '../../../../../../../src/core/public/mocks'; -const KibanaReactContext = createKibanaReactContext({ +const KibanaReactContext = createKibanaReactContext(({ + uiSettings: { get: () => true }, usageCollection: { reportUiCounter: () => {} }, -} as Partial); +} as unknown) as Partial); const mockParams = { rangeFrom: 'now-15m', diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 0067558865bd6d..f852db6c5cbcf2 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -15,6 +15,9 @@ import { import { i18n } from '@kbn/i18n'; import { keyBy } from 'lodash'; import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { useApmParams } from '../../../../hooks/use_apm_params'; +import { useApmRouter } from '../../../../hooks/use_apm_router'; import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values'; import { asMillisecondDuration, @@ -97,6 +100,10 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { urlParams: { start, end, environment, comparisonEnabled, comparisonType }, } = useUrlParams(); + const { + query: { rangeFrom, rangeTo, kuery }, + } = useApmParams('/services/:serviceName/overview'); + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, end, @@ -104,6 +111,8 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { comparisonType, }); + const apmRouter = useApmRouter(); + const columns: Array> = [ { field: 'name', @@ -138,7 +147,14 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { {item.name} ) : ( - item.name + + {item.name} + )} diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx index afed0be7cc2095..c7dd0f46cfc222 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/transaction_overview.test.tsx @@ -24,9 +24,10 @@ import { import { fromQuery } from '../../shared/Links/url_helpers'; import { TransactionOverview } from './'; -const KibanaReactContext = createKibanaReactContext({ +const KibanaReactContext = createKibanaReactContext(({ + uiSettings: { get: () => true }, usageCollection: { reportUiCounter: () => {} }, -} as Partial); +} as unknown) as Partial); const history = createMemoryHistory(); jest.spyOn(history, 'push'); diff --git a/x-pack/plugins/apm/public/components/routing/home/index.tsx b/x-pack/plugins/apm/public/components/routing/home/index.tsx index 454dcdedace901..185fcd1f920fc5 100644 --- a/x-pack/plugins/apm/public/components/routing/home/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/home/index.tsx @@ -9,6 +9,8 @@ import { Outlet } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; import React from 'react'; import { Redirect } from 'react-router-dom'; +import { BackendDetailOverview } from '../../app/backend_detail_overview'; +import { BackendInventory } from '../../app/backend_inventory'; import { Breadcrumb } from '../../app/breadcrumb'; import { ServiceInventory } from '../../app/service_inventory'; import { ServiceMap } from '../../app/service_map'; @@ -41,6 +43,13 @@ export const ServiceInventoryTitle = i18n.translate( } ); +export const BackendInventoryTitle = i18n.translate( + 'xpack.apm.views.backendInventory.title', + { + defaultMessage: 'Backends', + } +); + export const home = { path: '/', element: , @@ -48,6 +57,8 @@ export const home = { query: t.partial({ rangeFrom: t.string, rangeTo: t.string, + environment: t.string, + kuery: t.string, }), }), children: [ @@ -70,6 +81,26 @@ export const home = { }), element: , }), + { + path: '/backends', + element: , + children: [ + { + path: '/:backendName/overview', + element: , + params: t.type({ + path: t.type({ + backendName: t.string, + }), + }), + }, + page({ + path: '/', + title: BackendInventoryTitle, + element: , + }), + ], + }, { path: '/', element: , diff --git a/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx b/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx index 68c3edabfa44ef..fce7c2b2a2a69b 100644 --- a/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx @@ -7,73 +7,63 @@ import { EuiCard, - EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiImage, - EuiSpacer, EuiToolTip, } from '@elastic/eui'; +import type { Story } from '@storybook/react'; import React from 'react'; import { AGENT_NAMES } from '../../../../common/agent_name'; -import { useTheme } from '../../../hooks/use_theme'; import { getAgentIcon } from './get_agent_icon'; import { AgentIcon } from './index'; export default { - title: 'shared/icons', + title: 'shared/AgentIcon', component: AgentIcon, }; -export function AgentIcons() { - const theme = useTheme(); +export const List: Story = (_args, { globals }) => { + const darkMode = globals.euiTheme.includes('dark'); return ( - <> - - {''} - - - - - - {AGENT_NAMES.map((agentName) => { - return ( - - -

- - - -

- - } - title={agentName} - description={ -
+ + {AGENT_NAMES.map((agentName) => { + return ( + + +

- + -

- } - /> -
- ); - })} -
- +

+ + } + title={agentName} + description={ +
+ + + +
+ } + /> + + ); + })} + ); -} +}; diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx index ff2b95667a63a8..1439e59877ea4a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx @@ -14,7 +14,7 @@ import { ApmPluginContext, ApmPluginContextValue, } from '../../../../context/apm_plugin/apm_plugin_context'; -import { ApmServiceContextProvider } from '../../../../context/apm_service/apm_service_context'; +import { APMServiceContext } from '../../../../context/apm_service/apm_service_context'; import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context'; import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { @@ -41,6 +41,7 @@ export default { decorators: [ (Story: ComponentType, { args }: StoryContext) => { const { alertsResponse, latencyChartResponse } = args as Args; + const serviceName = 'testService'; const apmPluginContextMock = ({ core: { @@ -51,10 +52,8 @@ export default { basePath: { prepend: () => {} }, get: (endpoint: string) => { switch (endpoint) { - case '/api/apm/services/test-service/transactions/charts/latency': + case `/api/apm/services/${serviceName}/transactions/charts/latency`: return latencyChartResponse; - case '/api/apm/services/test-service/alerts': - return alertsResponse; default: return {}; } @@ -68,24 +67,32 @@ export default { createCallApmApi(apmPluginContextMock.core); + const transactionType = `${Math.random()}`; // So we don't memoize + return ( - - + + - + - + diff --git a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx index 58f3a4c61a8a14..3d1527a4737407 100644 --- a/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/managed_table/index.tsx @@ -19,7 +19,7 @@ export interface ITableColumn { field?: string; dataType?: string; align?: string; - width?: string | number; + width?: string; sortable?: boolean; render?: (value: any, item: T) => unknown; } diff --git a/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx b/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx index 7d2e2fbefc3590..5aec7e90287457 100644 --- a/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx @@ -7,13 +7,12 @@ import { EuiCard, - EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiImage, - EuiSpacer, EuiToolTip, } from '@elastic/eui'; +import type { Story } from '@storybook/react'; import React from 'react'; import { getSpanIcon, spanTypeIcons } from './get_span_icon'; import { SpanIcon } from './index'; @@ -21,72 +20,64 @@ import { SpanIcon } from './index'; const spanTypes = Object.keys(spanTypeIcons); export default { - title: 'shared/icons', + title: 'shared/SpanIcon', component: SpanIcon, }; -export function SpanIcons() { +export const List: Story = () => { return ( - <> - - {''} - + + {spanTypes.map((type) => { + const subTypes = Object.keys(spanTypeIcons[type]); + return subTypes.map((subType) => { + const id = `${type}.${subType}`; - - - - {spanTypes.map((type) => { - const subTypes = Object.keys(spanTypeIcons[type]); - return subTypes.map((subType) => { - const id = `${type}.${subType}`; - - return ( - - + return ( + + + + + +

+ } + title={id} + description={ + <> +
- + -

- } - title={id} - description={ - <> -
- - - -
+
- -
span.type: {type}
-
span.subtype: {subType}
-
- - } - /> -
- ); - }); - })} -
- + +
span.type: {type}
+
span.subtype: {subType}
+
+ + } + /> + + ); + }); + })} +
); -} +}; diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts index 77ae49bff7d847..da903e42bd3c73 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -57,6 +57,7 @@ describe('getTimeRangeComparison', () => { }); expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); + expect(result.offset).toEqual('1d'); }); }); describe('when a week before is selected', () => { @@ -71,6 +72,7 @@ describe('getTimeRangeComparison', () => { }); expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + expect(result.offset).toEqual('1w'); }); }); describe('when previous period is selected', () => { @@ -86,6 +88,7 @@ describe('getTimeRangeComparison', () => { expect(result).toEqual({ comparisonStart: '2021-02-09T14:24:02.174Z', comparisonEnd: '2021-02-09T14:40:01.087Z', + offset: '958913ms', }); }); }); @@ -104,6 +107,7 @@ describe('getTimeRangeComparison', () => { }); expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + expect(result.offset).toEqual('1w'); }); }); }); @@ -120,6 +124,7 @@ describe('getTimeRangeComparison', () => { }); expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); + expect(result.offset).toEqual('691200000ms'); }); it('uses the date difference to calculate the time range - 30 days', () => { @@ -133,6 +138,7 @@ describe('getTimeRangeComparison', () => { }); expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); + expect(result.offset).toEqual('2592000000ms'); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts index 67c27308e6658b..d9f9a249f1320f 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -60,14 +60,17 @@ export function getTimeRangeComparison({ const endEpoch = endMoment.valueOf(); let diff: number; + let offset: string; switch (comparisonType) { case TimeRangeComparisonType.DayBefore: diff = oneDayInMilliseconds; + offset = '1d'; break; case TimeRangeComparisonType.WeekBefore: diff = oneWeekInMilliseconds; + offset = '1w'; break; case TimeRangeComparisonType.PeriodBefore: @@ -77,6 +80,7 @@ export function getTimeRangeComparison({ unitOfTime: 'milliseconds', precise: true, }); + offset = `${diff}ms`; break; default: @@ -86,5 +90,6 @@ export function getTimeRangeComparison({ return { comparisonStart: new Date(startEpoch - diff).toISOString(), comparisonEnd: new Date(endEpoch - diff).toISOString(), + offset, }; } diff --git a/x-pack/plugins/apm/public/context/apm_backend/apm_backend_context.tsx b/x-pack/plugins/apm/public/context/apm_backend/apm_backend_context.tsx new file mode 100644 index 00000000000000..b04f88a24d32c1 --- /dev/null +++ b/x-pack/plugins/apm/public/context/apm_backend/apm_backend_context.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useMemo } from 'react'; +import { FETCH_STATUS, useFetcher } from '../../hooks/use_fetcher'; +import { useApmParams } from '../../hooks/use_apm_params'; +import { APIReturnType } from '../../services/rest/createCallApmApi'; +import { useUrlParams } from '../url_params_context/use_url_params'; + +export const ApmBackendContext = createContext< + | { + backendName: string; + metadata: { + data?: APIReturnType<'GET /api/apm/backends/{backendName}/metadata'>; + status?: FETCH_STATUS; + }; + } + | undefined +>(undefined); + +export function ApmBackendContextProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { + path: { backendName }, + } = useApmParams('/backends/:backendName/overview'); + + const { + urlParams: { start, end }, + } = useUrlParams(); + + const backendMetadataFetch = useFetcher( + (callApmApi) => { + if (!start || !end) { + return; + } + + return callApmApi({ + endpoint: 'GET /api/apm/backends/{backendName}/metadata', + params: { + path: { + backendName, + }, + query: { + start, + end, + }, + }, + }); + }, + [backendName, start, end] + ); + + const value = useMemo(() => { + return { + backendName, + metadata: { + data: backendMetadataFetch.data, + status: backendMetadataFetch.status, + }, + }; + }, [backendName, backendMetadataFetch.data, backendMetadataFetch.status]); + + return ( + + {children} + + ); +} diff --git a/x-pack/plugins/apm/public/context/apm_backend/use_apm_backend_context.tsx b/x-pack/plugins/apm/public/context/apm_backend/use_apm_backend_context.tsx new file mode 100644 index 00000000000000..5a48014c756621 --- /dev/null +++ b/x-pack/plugins/apm/public/context/apm_backend/use_apm_backend_context.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useContext } from 'react'; +import { ApmBackendContext } from './apm_backend_context'; + +export function useApmBackendContext() { + const context = useContext(ApmBackendContext); + + if (!context) { + throw new Error( + 'ApmBackendContext has no set value, did you forget rendering ApmBackendContextProvider?' + ); + } + + return context; +} diff --git a/x-pack/plugins/apm/public/hooks/use_comparison.ts b/x-pack/plugins/apm/public/hooks/use_comparison.ts new file mode 100644 index 00000000000000..b84942657dc9da --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_comparison.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../components/shared/time_comparison/get_time_range_comparison'; +import { useUrlParams } from '../context/url_params_context/use_url_params'; +import { useTheme } from './use_theme'; +import { useTimeRange } from './use_time_range'; + +export function useComparison() { + const theme = useTheme(); + + const comparisonChartTheme = getComparisonChartTheme(theme); + const { start, end } = useTimeRange(); + + const { + urlParams: { comparisonType, comparisonEnabled }, + } = useUrlParams(); + + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); + + return { + offset, + comparisonChartTheme, + }; +} diff --git a/x-pack/plugins/apm/public/hooks/use_time_range.ts b/x-pack/plugins/apm/public/hooks/use_time_range.ts new file mode 100644 index 00000000000000..7afdfb7db6a04f --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_time_range.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useUrlParams } from '../context/url_params_context/use_url_params'; + +export function useTimeRange() { + const { + urlParams: { start, end }, + } = useUrlParams(); + + if (!start || !end) { + throw new Error('Time range not set'); + } + + return { + start, + end, + }; +} diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 91b045b8db46f9..c5271b96376336 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -103,6 +103,10 @@ export class ApmPlugin implements Plugin { { defaultMessage: 'Service Map' } ); + const backendsTitle = i18n.translate('xpack.apm.navigation.backendsTitle', { + defaultMessage: 'Backends', + }); + // register observability nav if user has access to plugin plugins.observability.navigation.registerSections( from(core.getStartServices()).pipe( @@ -117,6 +121,7 @@ export class ApmPlugin implements Plugin { { label: servicesTitle, app: 'apm', path: '/services' }, { label: tracesTitle, app: 'apm', path: '/traces' }, { label: serviceMapTitle, app: 'apm', path: '/service-map' }, + { label: backendsTitle, app: 'apm', path: '/backends' }, ], }, @@ -242,6 +247,7 @@ export class ApmPlugin implements Plugin { { id: 'services', title: servicesTitle, path: '/services' }, { id: 'traces', title: tracesTitle, path: '/traces' }, { id: 'service-map', title: serviceMapTitle, path: '/service-map' }, + { id: 'backends', title: backendsTitle, path: '/backends' }, ], async mount(appMountParameters: AppMountParameters) { diff --git a/x-pack/plugins/apm/server/lib/backends/get_latency_charts_for_backend.ts b/x-pack/plugins/apm/server/lib/backends/get_latency_charts_for_backend.ts new file mode 100644 index 00000000000000..eed8a9a0995363 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/backends/get_latency_charts_for_backend.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, +} from '../../../common/elasticsearch_fieldnames'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { kqlQuery, rangeQuery } from '../../../../observability/server'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { Setup } from '../helpers/setup_request'; +import { getMetricsDateHistogramParams } from '../helpers/metrics'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; + +export async function getLatencyChartsForBackend({ + backendName, + setup, + start, + end, + environment, + kuery, + offset, +}: { + backendName: string; + setup: Setup; + start: number; + end: number; + environment?: string; + kuery?: string; + offset?: string; +}) { + const { apmEventClient } = setup; + + const offsetInMs = getOffsetInMs(start, offset); + const startWithOffset = start - offsetInMs; + const endWithOffset = end - offsetInMs; + + const response = await apmEventClient.search('get_latency_for_backend', { + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...environmentQuery(environment), + ...kqlQuery(kuery), + ...rangeQuery(startWithOffset, endWithOffset), + { term: { [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName } }, + ], + }, + }, + aggs: { + timeseries: { + date_histogram: getMetricsDateHistogramParams({ + start: startWithOffset, + end: endWithOffset, + metricsInterval: 60, + }), + aggs: { + latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, + }, + latency_count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, + }, + }, + }, + }, + }, + }); + + return ( + response.aggregations?.timeseries.buckets.map((bucket) => { + return { + x: bucket.key + offsetInMs, + y: (bucket.latency_sum.value ?? 0) / (bucket.latency_count.value ?? 0), + }; + }) ?? [] + ); +} diff --git a/x-pack/plugins/apm/server/lib/backends/get_metadata_for_backend.ts b/x-pack/plugins/apm/server/lib/backends/get_metadata_for_backend.ts new file mode 100644 index 00000000000000..9e49fd6d6c390c --- /dev/null +++ b/x-pack/plugins/apm/server/lib/backends/get_metadata_for_backend.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { maybe } from '../../../common/utils/maybe'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../common/elasticsearch_fieldnames'; +import { rangeQuery } from '../../../../observability/server'; +import { Setup } from '../helpers/setup_request'; + +export async function getMetadataForBackend({ + setup, + backendName, + start, + end, +}: { + setup: Setup; + backendName: string; + start: number; + end: number; +}) { + const { apmEventClient } = setup; + + const sampleResponse = await apmEventClient.search('get_backend_sample', { + apm: { + events: [ProcessorEvent.span], + }, + body: { + size: 1, + query: { + bool: { + filter: [ + { + term: { + [SPAN_DESTINATION_SERVICE_RESOURCE]: backendName, + }, + }, + ...rangeQuery(start, end), + ], + }, + }, + sort: { + '@timestamp': 'desc', + }, + }, + }); + + const sample = maybe(sampleResponse.hits.hits[0])?._source; + + return { + 'span.type': sample?.span.type, + 'span.subtype': sample?.span.subtype, + }; +} diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts index 291b2fa2af99d8..584f51bbf77a5f 100644 --- a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -116,6 +116,14 @@ export const apmConfigMapping: Record< name: 'rum_event_rate_lru_size', type: 'integer', }, + 'apm-server.rum.library_pattern': { + name: 'rum_library_pattern', + type: 'text', + }, + 'apm-server.rum.exclude_from_grouping': { + name: 'rum_exclude_from_grouping', + type: 'text', + }, 'apm-server.api_key.limit': { name: 'api_key_limit', type: 'integer', diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts index d6a1770a915918..d4f9b79e516cef 100644 --- a/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.test.ts @@ -8,6 +8,7 @@ import { ArtifactSourceMap, getPackagePolicyWithSourceMap, + getCleanedBundleFilePath, } from './source_maps'; const packagePolicy = { @@ -184,4 +185,19 @@ describe('Source maps', () => { }); }); }); + describe('getCleanedBundleFilePath', () => { + it('cleans url', () => { + expect( + getCleanedBundleFilePath( + 'http://localhost:8000/test/e2e/../e2e/general-usecase/bundle.js.map' + ) + ).toEqual('http://localhost:8000/test/e2e/general-usecase/bundle.js.map'); + }); + + it('returns same path when it is not a valid url', () => { + expect( + getCleanedBundleFilePath('/general-usecase/bundle.js.map') + ).toEqual('/general-usecase/bundle.js.map'); + }); + }); }); diff --git a/x-pack/plugins/apm/server/lib/fleet/source_maps.ts b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts index 10514ddcbdf620..8c11f80f21191d 100644 --- a/x-pack/plugins/apm/server/lib/fleet/source_maps.ts +++ b/x-pack/plugins/apm/server/lib/fleet/source_maps.ts @@ -168,3 +168,12 @@ export async function updateSourceMapsOnFleetPolicies({ }) ); } + +export function getCleanedBundleFilePath(bundleFilePath: string) { + try { + const cleanedBundleFilepath = new URL(bundleFilePath); + return cleanedBundleFilepath.href; + } catch (e) { + return bundleFilePath; + } +} diff --git a/x-pack/plugins/apm/server/lib/helpers/metrics.ts b/x-pack/plugins/apm/server/lib/helpers/metrics.ts index d3494797d852c8..41fd8c01bc2cc7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/metrics.ts +++ b/x-pack/plugins/apm/server/lib/helpers/metrics.ts @@ -7,11 +7,15 @@ import { getBucketSize } from './get_bucket_size'; -export function getMetricsDateHistogramParams( - start: number, - end: number, - metricsInterval: number -) { +export function getMetricsDateHistogramParams({ + start, + end, + metricsInterval, +}: { + start: number; + end: number; + metricsInterval: number; +}) { const { bucketSize } = getBucketSize({ start, end }); return { field: '@timestamp', diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index da17a77ed48f8c..9f8e1057ba5a97 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -77,11 +77,11 @@ export async function fetchAndTransformGcMetrics({ }, aggs: { timeseries: { - date_histogram: getMetricsDateHistogramParams( + date_histogram: getMetricsDateHistogramParams({ start, end, - config['xpack.apm.metricsInterval'] - ), + metricsInterval: config['xpack.apm.metricsInterval'], + }), aggs: { // get the max value max: { diff --git a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts index cd94eb85112823..723636bf4c2996 100644 --- a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts @@ -91,11 +91,11 @@ export async function fetchAndTransformMetrics({ }, aggs: { timeseriesData: { - date_histogram: getMetricsDateHistogramParams( + date_histogram: getMetricsDateHistogramParams({ start, end, - config['xpack.apm.metricsInterval'] - ), + metricsInterval: config['xpack.apm.metricsInterval'], + }), aggs, }, ...aggs, diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts index f5fcb3c2917ea4..0b0dd6924c4aa9 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -117,11 +117,11 @@ export async function getTransactionBreakdown({ aggs: { ...subAggs, by_date: { - date_histogram: getMetricsDateHistogramParams( + date_histogram: getMetricsDateHistogramParams({ start, end, - config['xpack.apm.metricsInterval'] - ), + metricsInterval: config['xpack.apm.metricsInterval'], + }), aggs: subAggs, }, }, diff --git a/x-pack/plugins/apm/server/routes/backends.ts b/x-pack/plugins/apm/server/routes/backends.ts new file mode 100644 index 00000000000000..3d0694425d3da5 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/backends.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { environmentRt, kueryRt, offsetRt, rangeRt } from './default_api_types'; +import { createApmServerRoute } from './create_apm_server_route'; +import { createApmServerRouteRepository } from './create_apm_server_route_repository'; +import { getMetadataForBackend } from '../lib/backends/get_metadata_for_backend'; +import { getLatencyChartsForBackend } from '../lib/backends/get_latency_charts_for_backend'; + +const backendMetadataRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/backends/{backendName}/metadata', + params: t.type({ + path: t.type({ + backendName: t.string, + }), + query: rangeRt, + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { backendName } = params.path; + + const { start, end } = setup; + + const metadata = await getMetadataForBackend({ + backendName, + setup, + start, + end, + }); + + return { metadata }; + }, +}); + +const backendLatencyChartsRoute = createApmServerRoute({ + endpoint: 'GET /api/apm/backends/{backendName}/charts/latency', + params: t.type({ + path: t.type({ + backendName: t.string, + }), + query: t.intersection([rangeRt, kueryRt, environmentRt, offsetRt]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources) => { + const setup = await setupRequest(resources); + const { params } = resources; + const { backendName } = params.path; + const { kuery, environment, offset } = params.query; + + const { start, end } = setup; + + const [currentTimeseries, comparisonTimeseries] = await Promise.all([ + getLatencyChartsForBackend({ + backendName, + setup, + start, + end, + kuery, + environment, + }), + offset + ? getLatencyChartsForBackend({ + backendName, + setup, + start, + end, + kuery, + environment, + offset, + }) + : null, + ]); + + return { currentTimeseries, comparisonTimeseries }; + }, +}); + +export const backendsRouteRepository = createApmServerRouteRepository() + .add(backendMetadataRoute) + .add(backendLatencyChartsRoute); diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 3181a3dbce7acb..16de83687eb7c1 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -13,6 +13,8 @@ export const rangeRt = t.type({ end: isoToEpochRt, }); +export const offsetRt = t.partial({ offset: t.string }); + export const comparisonRangeRt = t.partial({ comparisonStart: isoToEpochRt, comparisonEnd: isoToEpochRt, diff --git a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts index 4a277e2a423369..ad4b48c090e580 100644 --- a/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts +++ b/x-pack/plugins/apm/server/routes/get_global_apm_server_route_repository.ts @@ -11,6 +11,7 @@ import type { } from '@kbn/server-route-repository'; import { PickByValue } from 'utility-types'; import { alertsChartPreviewRouteRepository } from './alerts/chart_preview'; +import { backendsRouteRepository } from './backends'; import { correlationsRouteRepository } from './correlations'; import { createApmServerRouteRepository } from './create_apm_server_route_repository'; import { environmentsRouteRepository } from './environments'; @@ -52,7 +53,8 @@ const getTypedGlobalApmServerRouteRepository = () => { .merge(apmIndicesRouteRepository) .merge(customLinkRouteRepository) .merge(sourceMapsRouteRepository) - .merge(apmFleetRouteRepository); + .merge(apmFleetRouteRepository) + .merge(backendsRouteRepository); return repository; }; diff --git a/x-pack/plugins/apm/server/routes/source_maps.ts b/x-pack/plugins/apm/server/routes/source_maps.ts index d92bad31cd8d8f..6a455eca1258f5 100644 --- a/x-pack/plugins/apm/server/routes/source_maps.ts +++ b/x-pack/plugins/apm/server/routes/source_maps.ts @@ -13,6 +13,7 @@ import { deleteApmArtifact, listArtifacts, updateSourceMapsOnFleetPolicies, + getCleanedBundleFilePath, } from '../lib/fleet/source_maps'; import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; import { createApmServerRoute } from './create_apm_server_route'; @@ -78,6 +79,7 @@ const uploadSourceMapRoute = createApmServerRoute({ bundle_filepath: bundleFilepath, sourcemap: sourceMap, } = params.body; + const cleanedBundleFilepath = getCleanedBundleFilePath(bundleFilepath); const fleetPluginStart = await plugins.fleet?.start(); const coreStart = await core.start(); const esClient = coreStart.elasticsearch.client.asInternalUser; @@ -89,7 +91,7 @@ const uploadSourceMapRoute = createApmServerRoute({ apmArtifactBody: { serviceName, serviceVersion, - bundleFilepath, + bundleFilepath: cleanedBundleFilepath, sourceMap, }, }); diff --git a/x-pack/plugins/canvas/__fixtures__/function_specs.ts b/x-pack/plugins/canvas/__fixtures__/function_specs.ts index d82436434c0278..ff6c5643920803 100644 --- a/x-pack/plugins/canvas/__fixtures__/function_specs.ts +++ b/x-pack/plugins/canvas/__fixtures__/function_specs.ts @@ -8,7 +8,9 @@ import { functions as browserFns } from '../canvas_plugin_src/functions/browser'; import { ExpressionFunction } from '../../../../src/plugins/expressions'; import { initFunctions } from '../public/functions'; +import { functionSpecs as shapeFunctionSpecs } from '../../../../src/plugins/expression_shape/__fixtures__'; export const functionSpecs = browserFns .concat(...(initFunctions({} as any) as any)) - .map((fn) => new ExpressionFunction(fn())); + .map((fn) => new ExpressionFunction(fn())) + .concat(...shapeFunctionSpecs); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 9da646e6958612..5e7f837d9c6861 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -44,7 +44,6 @@ import { rounddate } from './rounddate'; import { rowCount } from './rowCount'; import { repeatImage } from './repeat_image'; import { seriesStyle } from './seriesStyle'; -import { shape } from './shape'; import { sort } from './sort'; import { staticColumn } from './staticColumn'; import { string } from './string'; @@ -96,7 +95,6 @@ export const functions = [ rounddate, rowCount, seriesStyle, - shape, sort, staticColumn, string, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.ts deleted file mode 100644 index a40aa50e856d17..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/shape.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; -import { getFunctionHelp } from '../../../i18n'; - -export enum Shape { - ARROW = 'arrow', - ARROW_MULTI = 'arrowMulti', - BOOKMARK = 'bookmark', - CIRCLE = 'circle', - CROSS = 'cross', - HEXAGON = 'hexagon', - KITE = 'kite', - PENTAGON = 'pentagon', - RHOMBUS = 'rhombus', - SEMICIRCLE = 'semicircle', - SPEECH_BUBBLE = 'speechBubble', - SQUARE = 'square', - STAR = 'star', - TAG = 'tag', - TRIANGLE = 'triangle', - TRIANGLE_RIGHT = 'triangleRight', -} - -interface Arguments { - border: string; - borderWidth: number; - shape: Shape; - fill: string; - maintainAspect: boolean; -} - -export interface Output extends Arguments { - type: 'shape'; -} - -export function shape(): ExpressionFunctionDefinition<'shape', null, Arguments, Output> { - const { help, args: argHelp } = getFunctionHelp().shape; - - return { - name: 'shape', - aliases: [], - type: 'shape', - inputTypes: ['null'], - help, - args: { - shape: { - types: ['string'], - help: argHelp.shape, - aliases: ['_'], - default: 'square', - options: Object.values(Shape), - }, - border: { - types: ['string'], - aliases: ['stroke'], - help: argHelp.border, - }, - borderWidth: { - types: ['number'], - aliases: ['strokeWidth'], - help: argHelp.borderWidth, - default: 0, - }, - fill: { - types: ['string'], - help: argHelp.fill, - default: 'black', - }, - maintainAspect: { - types: ['boolean'], - help: argHelp.maintainAspect, - default: false, - options: [true, false], - }, - }, - fn: (input, args) => ({ - type: 'shape', - ...args, - }), - }; -} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts index 906f1646757a7f..d04e342ccb9e3b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts @@ -12,7 +12,6 @@ import { pie } from './pie'; import { plot } from './plot'; import { progress } from './progress'; import { repeatImage } from './repeat_image'; -import { shape } from './shape'; import { table } from './table'; import { text } from './text'; @@ -24,7 +23,6 @@ export const renderFunctions = [ plot, progress, repeatImage, - shape, table, text, ]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts index 1d032aa829bc07..f24fad04fab50f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts @@ -7,6 +7,7 @@ import { revealImageRenderer } from '../../../../../src/plugins/expression_reveal_image/public'; import { errorRenderer, debugRenderer } from '../../../../../src/plugins/expression_error/public'; +import { shapeRenderer } from '../../../../../src/plugins/expression_shape/public'; -export const renderFunctions = [revealImageRenderer, errorRenderer, debugRenderer]; +export const renderFunctions = [revealImageRenderer, debugRenderer, errorRenderer, shapeRenderer]; export const renderFunctionFactories = []; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.ts deleted file mode 100644 index ca6c1fc2fb7be5..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { RendererStrings } from '../../../i18n'; -import { shapes } from './shapes'; -import { RendererFactory } from '../../../types'; -import { Output } from '../../functions/common/shape'; - -const { shape: strings } = RendererStrings; - -export const shape: RendererFactory = () => ({ - name: 'shape', - displayName: strings.getDisplayName(), - help: strings.getHelpDescription(), - reuseDomNode: true, - render(domNode, config, handlers) { - const { shape: shapeType, fill, border, borderWidth, maintainAspect } = config; - - const parser = new DOMParser(); - const shapeSvg = parser - .parseFromString(shapes[shapeType], 'image/svg+xml') - .getElementsByTagName('svg') - .item(0)!; - - const shapeContent = shapeSvg.firstElementChild!; - - if (fill) { - shapeContent.setAttribute('fill', fill); - } - if (border) { - shapeContent.setAttribute('stroke', border); - } - const strokeWidth = Math.max(borderWidth, 0); - shapeContent.setAttribute('stroke-width', String(strokeWidth)); - shapeContent.setAttribute('stroke-miterlimit', '999'); - shapeContent.setAttribute('vector-effect', 'non-scaling-stroke'); - - shapeSvg.setAttribute('preserveAspectRatio', maintainAspect ? 'xMidYMid meet' : 'none'); - shapeSvg.setAttribute('overflow', 'visible'); - - const initialViewBox = shapeSvg - .getAttribute('viewBox')! - .split(' ') - .map((v) => parseInt(v, 10)); - - const draw = () => { - const width = domNode.offsetWidth; - const height = domNode.offsetHeight; - - // adjust viewBox based on border width - let [minX, minY, shapeWidth, shapeHeight] = initialViewBox; - const borderOffset = strokeWidth; - - if (width) { - const xOffset = (shapeWidth / width) * borderOffset; - minX -= xOffset; - shapeWidth += xOffset * 2; - } else { - shapeWidth = 0; - } - - if (height) { - const yOffset = (shapeHeight / height) * borderOffset; - minY -= yOffset; - shapeHeight += yOffset * 2; - } else { - shapeHeight = 0; - } - - shapeSvg.setAttribute('width', String(width)); - shapeSvg.setAttribute('height', String(height)); - shapeSvg.setAttribute('viewBox', [minX, minY, shapeWidth, shapeHeight].join(' ')); - - const oldShape = domNode.firstElementChild; - if (oldShape) { - domNode.removeChild(oldShape); - } - - domNode.style.lineHeight = '0'; - domNode.appendChild(shapeSvg); - }; - - draw(); - handlers.done(); - handlers.onResize(draw); // debouncing avoided for fluidity - }, -}); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg deleted file mode 100644 index 2048bf5992c705..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg deleted file mode 100644 index 0d1fcfd435ad40..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/arrow_multi.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg deleted file mode 100644 index 5cb144bd3367a7..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/bookmark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg deleted file mode 100644 index d4024c138b710e..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg deleted file mode 100644 index 72f0b7b6d556a2..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/cross.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg deleted file mode 100644 index e1096039b44a87..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/hexagon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.ts deleted file mode 100644 index e5c0d22f5c3878..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import arrow from '!!raw-loader!./arrow.svg'; -import arrowMulti from '!!raw-loader!./arrow_multi.svg'; -import bookmark from '!!raw-loader!./bookmark.svg'; -import cross from '!!raw-loader!./cross.svg'; -import circle from '!!raw-loader!./circle.svg'; -import hexagon from '!!raw-loader!./hexagon.svg'; -import kite from '!!raw-loader!./kite.svg'; -import pentagon from '!!raw-loader!./pentagon.svg'; -import rhombus from '!!raw-loader!./rhombus.svg'; -import semicircle from '!!raw-loader!./semicircle.svg'; -import speechBubble from '!!raw-loader!./speech_bubble.svg'; -import square from '!!raw-loader!./square.svg'; -import star from '!!raw-loader!./star.svg'; -import tag from '!!raw-loader!./tag.svg'; -import triangle from '!!raw-loader!./triangle.svg'; -import triangleRight from '!!raw-loader!./triangle_right.svg'; - -export const shapes = { - arrow, - arrowMulti, - bookmark, - cross, - circle, - hexagon, - kite, - pentagon, - rhombus, - semicircle, - speechBubble, - square, - star, - tag, - triangle, - triangleRight, -}; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg deleted file mode 100644 index 76121bf0c57581..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/kite.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg deleted file mode 100644 index 578b095479b70f..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/pentagon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg deleted file mode 100644 index 9512d458f174a5..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/rhombus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg deleted file mode 100644 index 48b9928d33a28e..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/semicircle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg deleted file mode 100644 index 1feebc4d5de4b8..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/speech_bubble.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg deleted file mode 100644 index f964547722bbcb..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/square.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg deleted file mode 100644 index 66749baef6bca7..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/star.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg deleted file mode 100644 index 845d81c9dabe6c..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/tag.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg deleted file mode 100644 index 6642aaf7ed0ac8..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg b/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg deleted file mode 100644 index e3b6050654fd80..00000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/shape/shapes/triangle_right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js index 074503d8c9c7e8..e5b9ab049a3b97 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/string.js @@ -5,55 +5,48 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { compose, withProps } from 'recompose'; import { EuiFlexItem, EuiFlexGroup, EuiFieldText, EuiButton } from '@elastic/eui'; -import { get } from 'lodash'; -import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { ArgumentStrings } from '../../../i18n'; const { String: strings } = ArgumentStrings; -const StringArgInput = ({ updateValue, value, confirm, commit, argId }) => ( - - - commit(ev.target.value)} - /> - - {confirm && ( - - commit(value)}> - {confirm} - +const StringArgInput = ({ argValue, typeInstance, onValueChange, argId }) => { + const [value, setValue] = useState(argValue); + const confirm = typeInstance?.options?.confirm; + + useEffect(() => { + setValue(argValue); + }, [argValue]); + + const onChange = useCallback( + (ev) => { + const onChangeFn = confirm ? setValue : onValueChange; + onChangeFn(ev.target.value); + }, + [confirm, onValueChange] + ); + + return ( + + + - )} - -); + {confirm && ( + + onValueChange(value)}> + {confirm} + + + )} + + ); +}; StringArgInput.propTypes = { - updateValue: PropTypes.func.isRequired, - value: PropTypes.string.isRequired, - confirm: PropTypes.string, - commit: PropTypes.func.isRequired, argId: PropTypes.string.isRequired, -}; - -const EnhancedStringArgInput = compose( - withProps(({ onValueChange, typeInstance, argValue }) => ({ - confirm: get(typeInstance, 'options.confirm'), - commit: onValueChange, - value: argValue, - })), - createStatefulPropHoc('value') -)(StringArgInput); - -EnhancedStringArgInput.propTypes = { argValue: PropTypes.any.isRequired, onValueChange: PropTypes.func.isRequired, typeInstance: PropTypes.object.isRequired, @@ -63,5 +56,5 @@ export const string = () => ({ name: 'string', displayName: strings.getDisplayName(), help: strings.getHelp(), - simpleTemplate: templateFromReactComponent(EnhancedStringArgInput), + simpleTemplate: templateFromReactComponent(StringArgInput), }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js index 7eed55eb892458..a39b151a11f0b2 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/textarea.js @@ -5,22 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { compose, withProps } from 'recompose'; import { EuiFormRow, EuiTextArea, EuiSpacer, EuiButton } from '@elastic/eui'; -import { get } from 'lodash'; -import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { ArgumentStrings } from '../../../i18n'; const { Textarea: strings } = ArgumentStrings; -const TextAreaArgInput = ({ updateValue, value, confirm, commit, renderError, argId }) => { - if (typeof value !== 'string') { +const TextAreaArgInput = ({ argValue, typeInstance, onValueChange, renderError, argId }) => { + const confirm = typeInstance?.options?.confirm; + const [value, setValue] = useState(); + + const onChange = useCallback( + (ev) => { + const onChangeFn = confirm ? setValue : onValueChange; + onChangeFn(ev.target.value); + }, + [confirm, onValueChange] + ); + + useEffect(() => { + setValue(argValue); + }, [argValue]); + + if (typeof argValue !== 'string') { renderError(); return null; } + return (
@@ -31,11 +44,11 @@ const TextAreaArgInput = ({ updateValue, value, confirm, commit, renderError, ar rows={10} value={value} resize="none" - onChange={confirm ? updateValue : (ev) => commit(ev.target.value)} + onChange={onChange} /> - commit(value)}> + onValueChange(value)}> {confirm} @@ -44,33 +57,16 @@ const TextAreaArgInput = ({ updateValue, value, confirm, commit, renderError, ar }; TextAreaArgInput.propTypes = { - updateValue: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - confirm: PropTypes.string, - commit: PropTypes.func.isRequired, - renderError: PropTypes.func, - argId: PropTypes.string.isRequired, -}; - -const EnhancedTextAreaArgInput = compose( - withProps(({ onValueChange, typeInstance, argValue }) => ({ - confirm: get(typeInstance, 'options.confirm'), - commit: onValueChange, - value: argValue, - })), - createStatefulPropHoc('value') -)(TextAreaArgInput); - -EnhancedTextAreaArgInput.propTypes = { argValue: PropTypes.any.isRequired, onValueChange: PropTypes.func.isRequired, + renderError: PropTypes.func, + argId: PropTypes.string.isRequired, typeInstance: PropTypes.object.isRequired, - renderError: PropTypes.func.isRequired, }; export const textarea = () => ({ name: 'textarea', displayName: strings.getDisplayName(), help: strings.getHelp(), - template: templateFromReactComponent(EnhancedTextAreaArgInput), + template: templateFromReactComponent(TextAreaArgInput), }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js index b06a2339b3aeb2..1c84b0b27fd198 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/views/shape.js @@ -5,7 +5,7 @@ * 2.0. */ -import { shapes } from '../../renderers/shape/shapes'; +import { getAvailableShapes } from '../../../../../../src/plugins/expression_shape/common'; import { ViewStrings } from '../../../i18n'; const { Shape: strings } = ViewStrings; @@ -21,7 +21,7 @@ export const shape = () => ({ displayName: strings.getShapeDisplayName(), argType: 'shape', options: { - shapes, + shapes: getAvailableShapes(), }, }, { diff --git a/x-pack/plugins/canvas/i18n/functions/dict/shape.ts b/x-pack/plugins/canvas/i18n/functions/dict/shape.ts deleted file mode 100644 index 70b295607ec14f..00000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/shape.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { shape } from '../../../canvas_plugin_src/functions/common/shape'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { SVG } from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.shapeHelpText', { - defaultMessage: 'Creates a shape.', - }), - args: { - shape: i18n.translate('xpack.canvas.functions.shape.args.shapeHelpText', { - defaultMessage: 'Pick a shape.', - }), - border: i18n.translate('xpack.canvas.functions.shape.args.borderHelpText', { - defaultMessage: 'An {SVG} color for the border outlining the shape.', - values: { - SVG, - }, - }), - borderWidth: i18n.translate('xpack.canvas.functions.shape.args.borderWidthHelpText', { - defaultMessage: 'The thickness of the border.', - }), - fill: i18n.translate('xpack.canvas.functions.shape.args.fillHelpText', { - defaultMessage: 'An {SVG} color to fill the shape.', - values: { - SVG, - }, - }), - maintainAspect: i18n.translate('xpack.canvas.functions.shape.args.maintainAspectHelpText', { - defaultMessage: `Maintain the shape's original aspect ratio?`, - }), - }, -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index b72d410ddd63f4..4368e2c8a6084a 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -64,7 +64,6 @@ import { help as savedMap } from './dict/saved_map'; import { help as savedSearch } from './dict/saved_search'; import { help as savedVisualization } from './dict/saved_visualization'; import { help as seriesStyle } from './dict/series_style'; -import { help as shape } from './dict/shape'; import { help as sort } from './dict/sort'; import { help as staticColumn } from './dict/static_column'; import { help as string } from './dict/string'; @@ -224,7 +223,6 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ savedSearch, savedVisualization, seriesStyle, - shape, sort, staticColumn, string, diff --git a/x-pack/plugins/canvas/i18n/renderers.ts b/x-pack/plugins/canvas/i18n/renderers.ts index fa1fbc063dbe6e..fcdb3382af4ea8 100644 --- a/x-pack/plugins/canvas/i18n/renderers.ts +++ b/x-pack/plugins/canvas/i18n/renderers.ts @@ -129,16 +129,6 @@ export const RendererStrings = { defaultMessage: 'Repeat an image a given number of times', }), }, - shape: { - getDisplayName: () => - i18n.translate('xpack.canvas.renderer.shape.displayName', { - defaultMessage: 'Shape', - }), - getHelpDescription: () => - i18n.translate('xpack.canvas.renderer.shape.helpDescription', { - defaultMessage: 'Render a basic shape', - }), - }, table: { getDisplayName: () => i18n.translate('xpack.canvas.renderer.table.displayName', { diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 545eae742a89e5..a98fc3d210c112 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -13,6 +13,7 @@ "expressionError", "expressionRevealImage", "expressions", + "expressionShape", "features", "inspector", "presentationUtil", diff --git a/x-pack/plugins/canvas/public/components/shape_picker/__stories__/__snapshots__/shape_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_picker/__stories__/__snapshots__/shape_picker.stories.storyshot index 70192deca5325c..3dc608196805a3 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/__stories__/__snapshots__/shape_picker.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_picker/__stories__/__snapshots__/shape_picker.stories.storyshot @@ -15,14 +15,18 @@ exports[`Storyshots components/Shapes/ShapePicker default 1`] = ` >
- - ", - } - } - /> + > + + + +
- - ", - } - } - /> + > + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
diff --git a/x-pack/plugins/canvas/public/components/shape_picker/__stories__/shape_picker.stories.tsx b/x-pack/plugins/canvas/public/components/shape_picker/__stories__/shape_picker.stories.tsx index f6357e3976c23c..5fdea5591f538c 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/__stories__/shape_picker.stories.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker/__stories__/shape_picker.stories.tsx @@ -9,9 +9,8 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; import { ShapePicker } from '../shape_picker'; - -import { shapes } from '../../../../canvas_plugin_src/renderers/shape/shapes'; +import { getAvailableShapes } from '../../../../../../../src/plugins/expression_shape/common'; storiesOf('components/Shapes/ShapePicker', module).add('default', () => ( - + )); diff --git a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx index 78cd989543ca90..e06a199f85feee 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx @@ -9,29 +9,24 @@ import React, { FC } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGrid, EuiFlexItem, EuiLink } from '@elastic/eui'; import { ShapePreview } from '../shape_preview'; +import { Shape } from '../../../../../../src/plugins/expression_shape/common'; interface Props { - shapes: { - [key: string]: string; - }; + shapes: Shape[]; onChange?: (key: string) => void; } -export const ShapePicker: FC = ({ shapes, onChange = () => {} }) => { - return ( - - {Object.keys(shapes) - .sort() - .map((shapeKey) => ( - - onChange(shapeKey)}> - - - - ))} - - ); -}; +export const ShapePicker: FC = ({ shapes, onChange = () => {} }) => ( + + {shapes.sort().map((shapeKey) => ( + + onChange(shapeKey)}> + + + + ))} + +); ShapePicker.propTypes = { onChange: PropTypes.func, diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot index aca6e9770573c0..247616e8429564 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/__snapshots__/shape_picker_popover.stories.storyshot @@ -53,14 +53,21 @@ exports[`Storyshots components/Shapes/ShapePickerPopover interactive 1`] = ` >
- - ", - } - } - /> + > + + + +
@@ -90,14 +97,21 @@ exports[`Storyshots components/Shapes/ShapePickerPopover shape selected 1`] = ` >
- - ", - } - } - /> + > + + + +
diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/shape_picker_popover.stories.tsx b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/shape_picker_popover.stories.tsx index f91f509318b475..babc03b8f6763b 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/shape_picker_popover.stories.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/__stories__/shape_picker_popover.stories.tsx @@ -9,18 +9,20 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; import { ShapePickerPopover } from '../shape_picker_popover'; - -import { shapes } from '../../../../canvas_plugin_src/renderers/shape/shapes'; +import { + getAvailableShapes, + Shape, +} from '../../../../../../../src/plugins/expression_shape/common'; class Interactive extends React.Component<{}, { value: string }> { public state = { - value: 'square', + value: Shape.SQUARE, }; public render() { return ( this.setState({ value })} value={this.state.value} /> @@ -29,9 +31,15 @@ class Interactive extends React.Component<{}, { value: string }> { } storiesOf('components/Shapes/ShapePickerPopover', module) - .add('default', () => ) + .add('default', () => ( + + )) .add('shape selected', () => ( - + )) .add('interactive', () => , { info: { diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx b/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx index cb3556c2c0fef4..5701c3cb1e799e 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx @@ -11,13 +11,12 @@ import { EuiLink, EuiPanel } from '@elastic/eui'; import { Popover } from '../popover'; import { ShapePicker } from '../shape_picker'; import { ShapePreview } from '../shape_preview'; +import { Shape } from '../../../../../../src/plugins/expression_shape/common'; interface Props { - shapes: { - [key: string]: string; - }; + shapes: Shape[]; onChange?: (key: string) => void; - value?: string; + value?: Shape; ariaLabel?: string; } @@ -25,7 +24,7 @@ export const ShapePickerPopover: FC = ({ shapes, onChange, value, ariaLab const button = (handleClick: React.MouseEventHandler) => ( - + ); diff --git a/x-pack/plugins/canvas/public/components/shape_preview/__stories__/__snapshots__/shape_preview.stories.storyshot b/x-pack/plugins/canvas/public/components/shape_preview/__stories__/__snapshots__/shape_preview.stories.storyshot index 8c7ff2e92b86d8..747938d33f532c 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/__stories__/__snapshots__/shape_preview.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/shape_preview/__stories__/__snapshots__/shape_preview.stories.storyshot @@ -3,25 +3,36 @@ exports[`Storyshots components/Shapes/ShapePreview arrow 1`] = `
- - ", - } - } -/> +> + + + +
`; exports[`Storyshots components/Shapes/ShapePreview square 1`] = `
- - ", - } - } -/> +> + + + +
`; diff --git a/x-pack/plugins/canvas/public/components/shape_preview/__stories__/shape_preview.stories.tsx b/x-pack/plugins/canvas/public/components/shape_preview/__stories__/shape_preview.stories.tsx index 1a135bae1c096a..de7ce4b411860a 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/__stories__/shape_preview.stories.tsx +++ b/x-pack/plugins/canvas/public/components/shape_preview/__stories__/shape_preview.stories.tsx @@ -8,9 +8,8 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { ShapePreview } from '../shape_preview'; - -import { shapes } from '../../../../canvas_plugin_src/renderers/shape/shapes'; +import { Shape } from '../../../../../../../src/plugins/expression_shape/public'; storiesOf('components/Shapes/ShapePreview', module) - .add('arrow', () => ) - .add('square', () => ); + .add('arrow', () => ) + .add('square', () => ); diff --git a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx index 7fbb28b771f39a..22d0d8cca84ef1 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx +++ b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx @@ -5,45 +5,55 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, RefCallback, useCallback, useState } from 'react'; import PropTypes from 'prop-types'; +import { + LazyShapeDrawer, + Shape, + ShapeDrawerComponentProps, + getDefaultShapeData, + SvgConfig, + ShapeRef, + ViewBoxParams, +} from '../../../../../../src/plugins/expression_shape/public'; +import { withSuspense } from '../../../../../../src/plugins/presentation_util/public'; interface Props { - shape?: string; + shape?: Shape; +} + +const ShapeDrawer = withSuspense(LazyShapeDrawer); + +function getViewBox(defaultWidth: number, defaultViewBox: ViewBoxParams): ViewBoxParams { + const { minX, minY, width, height } = defaultViewBox; + return { + minX: minX - defaultWidth / 2, + minY: minY - defaultWidth / 2, + width: width + defaultWidth, + height: height + defaultWidth, + }; } export const ShapePreview: FC = ({ shape }) => { - if (!shape) { - return
; - } - - const weight = 5; - const parser = new DOMParser(); - const shapeSvg = parser - .parseFromString(shape, 'image/svg+xml') - .getElementsByTagName('svg') - .item(0); - - if (!shapeSvg) { - throw new Error('An unexpected error occurred: the SVG was not parseable'); - } - - shapeSvg.setAttribute('fill', 'none'); - shapeSvg.setAttribute('stroke', 'black'); - - const viewBox = shapeSvg.getAttribute('viewBox') || '0 0 0 0'; - const initialViewBox = viewBox.split(' ').map((v: string) => parseInt(v, 10)); - - let [minX, minY, width, height] = initialViewBox; - minX -= weight / 2; - minY -= weight / 2; - width += weight; - height += weight; - shapeSvg.setAttribute('viewBox', [minX, minY, width, height].join(' ')); + const [shapeData, setShapeData] = useState(getDefaultShapeData()); + + const shapeRef = useCallback>((node) => { + if (node !== null) setShapeData(node.getData()); + }, []); + if (!shape) return
; return ( - // eslint-disable-next-line react/no-danger -
+
+ +
); }; diff --git a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js index df894a65afab17..bb5880b7f40a9b 100644 --- a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js +++ b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js @@ -12,7 +12,6 @@ import { metric } from '../canvas_plugin_src/renderers/metric'; import { pie } from '../canvas_plugin_src/renderers/pie'; import { plot } from '../canvas_plugin_src/renderers/plot'; import { progress } from '../canvas_plugin_src/renderers/progress'; -import { shape } from '../canvas_plugin_src/renderers/shape'; import { table } from '../canvas_plugin_src/renderers/table'; import { text } from '../canvas_plugin_src/renderers/text'; import { revealImageRenderer as revealImage } from '../../../../src/plugins/expression_reveal_image/public'; @@ -20,6 +19,7 @@ import { errorRenderer as error, debugRenderer as debug, } from '../../../../src/plugins/expression_error/public'; +import { shapeRenderer as shape } from '../../../../src/plugins/expression_shape/public'; /** * This is a collection of renderers which are bundled with the runtime. If diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.tsx b/x-pack/plugins/canvas/storybook/storyshots.test.tsx index 643bd1dc22041d..f99a1ed5d4335a 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.tsx +++ b/x-pack/plugins/canvas/storybook/storyshots.test.tsx @@ -39,19 +39,6 @@ jest.mock('../public/lib/ui_metric', () => ({ trackCanvasUiMetric: () => {} })); // Mock EUI generated ids to be consistently predictable for snapshots. jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); -// Jest automatically mocks SVGs to be a plain-text string that isn't an SVG. Canvas uses -// them in examples, so let's mock a few for tests. -jest.mock('../canvas_plugin_src/renderers/shape/shapes', () => ({ - shapes: { - arrow: ` - - `, - square: ` - - `, - }, -})); - // Mock react-datepicker dep used by eui to avoid rendering the entire large component jest.mock('@elastic/eui/packages/react-datepicker', () => { return { diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index bf9544a173f16f..8c97a78da7f0fc 100644 --- a/x-pack/plugins/canvas/tsconfig.json +++ b/x-pack/plugins/canvas/tsconfig.json @@ -33,6 +33,7 @@ { "path": "../../../src/plugins/expressions/tsconfig.json" }, { "path": "../../../src/plugins/expression_error/tsconfig.json" }, { "path": "../../../src/plugins/expression_reveal_image/tsconfig.json" }, + { "path": "../../../src/plugins/expression_shape/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, { "path": "../../../src/plugins/inspector/tsconfig.json" }, { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, diff --git a/x-pack/plugins/cloud/public/fullstory.ts b/x-pack/plugins/cloud/public/fullstory.ts index a5b735bce93874..25d5320a063bdd 100644 --- a/x-pack/plugins/cloud/public/fullstory.ts +++ b/x-pack/plugins/cloud/public/fullstory.ts @@ -5,27 +5,30 @@ * 2.0. */ -import { sha256 } from 'js-sha256'; +import { sha256 } from 'js-sha256'; // loaded here to reduce page load bundle size when FullStory is disabled import type { IBasePath, PackageInfo } from '../../../../src/core/public'; export interface FullStoryDeps { basePath: IBasePath; orgId: string; packageInfo: PackageInfo; - userId?: string; } -interface FullStoryApi { +export interface FullStoryApi { identify(userId: string, userVars?: Record): void; event(eventName: string, eventProperties: Record): void; } -export const initializeFullStory = async ({ +export interface FullStoryService { + fullStory: FullStoryApi; + sha256: typeof sha256; +} + +export const initializeFullStory = ({ basePath, orgId, packageInfo, - userId, -}: FullStoryDeps) => { +}: FullStoryDeps): FullStoryService => { // @ts-expect-error window._fs_debug = false; // @ts-expect-error @@ -75,22 +78,8 @@ export const initializeFullStory = async ({ // @ts-expect-error const fullStory: FullStoryApi = window.FSKibana; - try { - // This needs to be called syncronously to be sure that we populate the user ID soon enough to make sessions merging - // across domains work - if (userId) { - // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs - const hashedId = sha256(userId.toString()); - fullStory.identify(hashedId); - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(`[cloud.full_story] Could not call FS.identify due to error: ${e.toString()}`, e); - } - - // Record an event that Kibana was opened so we can easily search for sessions that use Kibana - fullStory.event('Loaded Kibana', { - // `str` suffix is required, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 - kibana_version_str: packageInfo.version, - }); + return { + fullStory, + sha256, + }; }; diff --git a/x-pack/plugins/cloud/public/plugin.test.mocks.ts b/x-pack/plugins/cloud/public/plugin.test.mocks.ts index 889b8492d5b1ba..4eb206d07bf85a 100644 --- a/x-pack/plugins/cloud/public/plugin.test.mocks.ts +++ b/x-pack/plugins/cloud/public/plugin.test.mocks.ts @@ -5,9 +5,17 @@ * 2.0. */ -import type { FullStoryDeps } from './fullstory'; +import { sha256 } from 'js-sha256'; +import type { FullStoryDeps, FullStoryApi, FullStoryService } from './fullstory'; -export const initializeFullStoryMock = jest.fn(); +export const fullStoryApiMock: jest.Mocked = { + event: jest.fn(), + identify: jest.fn(), +}; +export const initializeFullStoryMock = jest.fn(() => ({ + fullStory: fullStoryApiMock, + sha256, +})); jest.doMock('./fullstory', () => { return { initializeFullStory: initializeFullStoryMock }; }); diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 264ae61c050e8e..9b3ddc8e7294ea 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -9,14 +9,14 @@ import { nextTick } from '@kbn/test/jest'; import { coreMock } from 'src/core/public/mocks'; import { homePluginMock } from 'src/plugins/home/public/mocks'; import { securityMock } from '../../security/public/mocks'; -import { initializeFullStoryMock } from './plugin.test.mocks'; +import { fullStoryApiMock, initializeFullStoryMock } from './plugin.test.mocks'; import { CloudPlugin, CloudConfigType, loadFullStoryUserId } from './plugin'; describe('Cloud Plugin', () => { describe('#setup', () => { describe('setupFullstory', () => { beforeEach(() => { - initializeFullStoryMock.mockReset(); + jest.clearAllMocks(); }); const setupPlugin = async ({ @@ -63,23 +63,72 @@ describe('Cloud Plugin', () => { }); expect(initializeFullStoryMock).toHaveBeenCalled(); - const { basePath, orgId, packageInfo, userId } = initializeFullStoryMock.mock.calls[0][0]; + const { basePath, orgId, packageInfo } = initializeFullStoryMock.mock.calls[0][0]; expect(basePath.prepend).toBeDefined(); expect(orgId).toEqual('foo'); expect(packageInfo).toEqual(initContext.env.packageInfo); - expect(userId).toEqual('1234'); }); - it('passes undefined user ID when security is not available', async () => { + it('calls FS.identify with hashed user ID when security is available', async () => { + await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' } }, + currentUserProps: { + username: '1234', + }, + }); + + expect(fullStoryApiMock.identify).toHaveBeenCalledWith( + '03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4' + ); + }); + + it('does not call FS.identify when security is not available', async () => { await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, securityEnabled: false, }); - expect(initializeFullStoryMock).toHaveBeenCalled(); - const { orgId, userId } = initializeFullStoryMock.mock.calls[0][0]; - expect(orgId).toEqual('foo'); - expect(userId).toEqual(undefined); + expect(fullStoryApiMock.identify).not.toHaveBeenCalled(); + }); + + it('calls FS.event when security is available', async () => { + const { initContext } = await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' } }, + currentUserProps: { + username: '1234', + }, + }); + + expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version_str: initContext.env.packageInfo.version, + }); + }); + + it('calls FS.event when security is not available', async () => { + const { initContext } = await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' } }, + securityEnabled: false, + }); + + expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version_str: initContext.env.packageInfo.version, + }); + }); + + it('calls FS.event when FS.identify throws an error', async () => { + fullStoryApiMock.identify.mockImplementationOnce(() => { + throw new Error(`identify failed!`); + }); + const { initContext } = await setupPlugin({ + config: { full_story: { enabled: true, org_id: 'foo' } }, + currentUserProps: { + username: '1234', + }, + }); + + expect(fullStoryApiMock.event).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version_str: initContext.env.packageInfo.version, + }); }); it('does not call initializeFullStory when enabled=false', async () => { diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 98017d09ef8074..16c11d569c5f7f 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -162,7 +162,7 @@ export class CloudPlugin implements Plugin { }: CloudSetupDependencies & { basePath: IBasePath }) { const { enabled, org_id: orgId } = this.config.full_story; if (!enabled || !orgId) { - return; + return; // do not load any fullstory code in the browser if not enabled } // Keep this import async so that we do not load any FullStory code into the browser when it is disabled. @@ -171,16 +171,39 @@ export class CloudPlugin implements Plugin { ? loadFullStoryUserId({ getCurrentUser: security.authc.getCurrentUser }) : Promise.resolve(undefined); + // We need to call FS.identify synchronously after FullStory is initialized, so we must load the user upfront const [{ initializeFullStory }, userId] = await Promise.all([ fullStoryChunkPromise, userIdPromise, ]); - initializeFullStory({ + const { fullStory, sha256 } = initializeFullStory({ basePath, orgId, packageInfo: this.initializerContext.env.packageInfo, - userId, + }); + + // Very defensive try/catch to avoid any UnhandledPromiseRejections + try { + // This needs to be called syncronously to be sure that we populate the user ID soon enough to make sessions merging + // across domains work + if (userId) { + // Do the hashing here to keep it at clear as possible in our source code that we do not send literal user IDs + const hashedId = sha256(userId.toString()); + fullStory.identify(hashedId); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error( + `[cloud.full_story] Could not call FS.identify due to error: ${e.toString()}`, + e + ); + } + + // Record an event that Kibana was opened so we can easily search for sessions that use Kibana + fullStory.event('Loaded Kibana', { + // `str` suffix is required, see docs: https://help.fullstory.com/hc/en-us/articles/360020623234 + kibana_version_str: this.initializerContext.env.packageInfo.version, }); } } diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index ca465aef6d13e0..3764e4d483dabd 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -84,3 +84,5 @@ export const ERROR_CONNECTING_HEADER = 'x-ent-search-error-connecting'; export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search'; + +export const LOGS_SOURCE_ID = 'ent-search-logs'; diff --git a/x-pack/plugins/enterprise_search/kibana.json b/x-pack/plugins/enterprise_search/kibana.json index 723b24f951434c..9df01988fbd89d 100644 --- a/x-pack/plugins/enterprise_search/kibana.json +++ b/x-pack/plugins/enterprise_search/kibana.json @@ -2,9 +2,9 @@ "id": "enterpriseSearch", "version": "kibana", "kibanaVersion": "kibana", - "requiredPlugins": ["features", "licensing", "charts"], + "requiredPlugins": ["features", "spaces", "security", "licensing", "data", "charts", "infra"], "configPath": ["enterpriseSearch"], - "optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"], + "optionalPlugins": ["usageCollection", "home", "cloud"], "server": true, "ui": true, "requiredBundles": ["home", "kibanaReact"], diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 67ec06970a77ee..04fa3ae681045f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -16,6 +16,8 @@ import { Store } from 'redux'; import { I18nProvider } from '@kbn/i18n/react'; import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; +import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { InitialAppData } from '../../common/types'; import { PluginsStart, ClientConfigType, ClientData } from '../plugin'; @@ -68,12 +70,16 @@ export const renderApp = ( ReactDOM.render( - - - - - - + + + + + + + + + + , params.element ); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts index 39392d0c5c78e6..4cc907c3de9e4c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.test.ts @@ -33,12 +33,6 @@ describe('KibanaLogic', () => { expect(KibanaLogic.values.config).toEqual({}); }); - it('gracefully handles disabled security', () => { - mountKibanaLogic({ ...mockKibanaValues, security: undefined } as any); - - expect(KibanaLogic.values.security).toEqual({}); - }); - it('gracefully handles non-cloud installs', () => { mountKibanaLogic({ ...mockKibanaValues, cloud: undefined } as any); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts index d3b76f8dee9f0c..5a894c7b007485 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana/kibana_logic.ts @@ -29,14 +29,13 @@ interface KibanaLogicProps { renderHeaderActions(HeaderActions: FC): void; // Required plugins charts: ChartsPluginStart; + security: SecurityPluginStart; // Optional plugins cloud?: CloudSetup; - security?: SecurityPluginStart; } -export interface KibanaValues extends Omit { +export interface KibanaValues extends Omit { navigateToUrl(path: string, options?: CreateHrefOptions): Promise; cloud: Partial; - security: Partial; } export const KibanaLogic = kea>({ @@ -54,7 +53,7 @@ export const KibanaLogic = kea>({ }, {}, ], - security: [props.security || {}, {}], + security: [props.security, {}], setBreadcrumbs: [props.setBreadcrumbs, {}], setChromeIsVisible: [props.setChromeIsVisible, {}], setDocTitle: [props.setDocTitle, {}], diff --git a/x-pack/plugins/security_solution/server/lib/configuration/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/index.ts similarity index 83% rename from x-pack/plugins/security_solution/server/lib/configuration/index.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/log_stream/index.ts index 5d7c09c54b8c14..fc37e9ae317f41 100644 --- a/x-pack/plugins/security_solution/server/lib/configuration/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './adapter_types'; +export { EntSearchLogStream } from './log_stream'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx new file mode 100644 index 00000000000000..a934afb3b0d298 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { LogStream } from '../../../../../infra/public'; + +import { EntSearchLogStream } from './'; + +describe('EntSearchLogStream', () => { + const mockDateNow = jest.spyOn(global.Date, 'now').mockReturnValue(160000000); + + describe('renders with default props', () => { + const wrapper = shallow(); + + it('renders a LogStream component', () => { + expect(wrapper.type()).toEqual(LogStream); + }); + + it('renders with the enterprise search log source ID', () => { + expect(wrapper.prop('sourceId')).toEqual('ent-search-logs'); + }); + + it('renders with a default last-24-hours timestamp if no timestamp is passed', () => { + expect(wrapper.prop('startTimestamp')).toEqual(73600000); + expect(wrapper.prop('endTimestamp')).toEqual(160000000); + }); + }); + + describe('renders custom props', () => { + it('overrides the default props', () => { + const wrapper = shallow( + + ); + + expect(wrapper.prop('sourceId')).toEqual('test'); + expect(wrapper.prop('startTimestamp')).toEqual(1); + expect(wrapper.prop('endTimestamp')).toEqual(2); + }); + + it('allows passing a custom hoursAgo that modifies the default start timestamp', () => { + const wrapper = shallow(); + + expect(wrapper.prop('startTimestamp')).toEqual(156400000); + expect(wrapper.prop('endTimestamp')).toEqual(160000000); + }); + + it('allows passing any prop that the LogStream component takes', () => { + const wrapper = shallow( + + ); + + expect(wrapper.prop('height')).toEqual(500); + expect(wrapper.prop('highlight')).toEqual('some-log-id'); + expect(wrapper.prop('columns')).toBeTruthy(); + expect(wrapper.prop('filters')).toEqual([]); + }); + }); + + afterAll(() => mockDateNow.mockRestore()); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.tsx new file mode 100644 index 00000000000000..e5dabdd51e543b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/log_stream/log_stream.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { LogStream, LogStreamProps } from '../../../../../infra/public'; + +import { LOGS_SOURCE_ID } from '../../../../common/constants'; + +/* + * EnterpriseSearchLogStream is a light wrapper on top of infra's embeddable LogStream component. + * It prepopulates our log source ID (set in server/plugin.ts) and sets a basic 24-hours-ago + * default for timestamps. All other props get passed as-is to the underlying LogStream. + * + * Documentation links for reference: + * - https://github.com/elastic/kibana/blob/master/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx + * - Run `yarn storybook infra` for live docs + */ + +interface Props extends Omit { + startTimestamp?: LogStreamProps['startTimestamp']; + endTimestamp?: LogStreamProps['endTimestamp']; + hoursAgo?: number; +} + +export const EntSearchLogStream: React.FC = ({ + startTimestamp, + endTimestamp, + hoursAgo = 24, + ...props +}) => { + if (!endTimestamp) endTimestamp = Date.now(); + if (!startTimestamp) startTimestamp = endTimestamp - hoursAgo * 60 * 60 * 1000; + + return ( + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts index c9fa16ac05a16c..583652de1fa028 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/role_mapping/constants.ts @@ -318,7 +318,7 @@ export const USERS_HEADING_DESCRIPTION = i18n.translate( 'xpack.enterpriseSearch.roleMapping.usersHeadingDescription', { defaultMessage: - 'User management provides granular access for individual or special permission needs. Users from federated sources such as SAML are managed by role mappings, and excluded from this list.', + 'User management provides granular access for individual or special permission needs. Some users may be excluded from this list. These include users from federated sources such as SAML, which are managed by role mappings, and built-in user accounts such as the “elastic” or “enterprise_search” users.', } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx index 313d3ffa59d48f..2e92a00e3aa12c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/account_settings/account_settings.tsx @@ -20,11 +20,11 @@ export const AccountSettings: React.FC = () => { const [currentUser, setCurrentUser] = useState(null); useEffect(() => { - security!.authc!.getCurrentUser().then(setCurrentUser); + security.authc.getCurrentUser().then(setCurrentUser); }, [security.authc]); - const PersonalInfo = useMemo(() => security!.uiApi!.components.getPersonalInfo, [security.uiApi]); - const ChangePassword = useMemo(() => security!.uiApi!.components.getChangePassword, [ + const PersonalInfo = useMemo(() => security.uiApi.components.getPersonalInfo, [security.uiApi]); + const ChangePassword = useMemo(() => security.uiApi.components.getChangePassword, [ security.uiApi, ]); diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 6e521efc369df1..db781594e6b177 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -15,6 +15,7 @@ import { DEFAULT_APP_CATEGORIES, } from '../../../../src/core/public'; import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FeatureCatalogueCategory, HomePublicPluginSetup, @@ -43,13 +44,14 @@ export interface ClientData extends InitialAppData { interface PluginsSetup { cloud?: CloudSetup; home?: HomePublicPluginSetup; - security?: SecurityPluginSetup; + security: SecurityPluginSetup; } export interface PluginsStart { cloud?: CloudSetup; licensing: LicensingPluginStart; charts: ChartsPluginStart; - security?: SecurityPluginStart; + data: DataPublicPluginStart; + security: SecurityPluginStart; } export class EnterpriseSearchPlugin implements Plugin { diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts index ed105eed9dd6ee..9d62c0794651e9 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.test.ts @@ -52,6 +52,30 @@ describe('checkAccess', () => { spaces: mockSpaces, } as any; + describe('when security is disabled', () => { + it('should deny all access', async () => { + const security = { + authz: { mode: { useRbacForRequest: () => false } }, + }; + expect(await checkAccess({ ...mockDependencies, security })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + + describe('when the current request is unauthenticated', () => { + it('should deny all access', async () => { + const request = { + auth: { isAuthenticated: false }, + }; + expect(await checkAccess({ ...mockDependencies, request })).toEqual({ + hasAppSearchAccess: false, + hasWorkplaceSearchAccess: false, + }); + }); + }); + describe('when the space is disabled', () => { it('should deny all access', async () => { mockSpaces.spacesService.getActiveSpace.mockResolvedValueOnce(disabledSpace); @@ -63,17 +87,6 @@ describe('checkAccess', () => { }); describe('when the Spaces plugin is unavailable', () => { - describe('when security is disabled', () => { - it('should allow all access', async () => { - const spaces = undefined; - const security = undefined; - expect(await checkAccess({ ...mockDependencies, spaces, security })).toEqual({ - hasAppSearchAccess: true, - hasWorkplaceSearchAccess: true, - }); - }); - }); - describe('when getActiveSpace returns 403 forbidden', () => { it('should deny all access', async () => { mockSpaces.spacesService.getActiveSpace.mockReturnValueOnce( @@ -105,16 +118,6 @@ describe('checkAccess', () => { mockSpaces.spacesService.getActiveSpace.mockResolvedValueOnce(enabledSpace); }); - describe('when security is disabled', () => { - it('should allow all access', async () => { - const security = undefined; - expect(await checkAccess({ ...mockDependencies, security })).toEqual({ - hasAppSearchAccess: true, - hasWorkplaceSearchAccess: true, - }); - }); - }); - describe('when the user is a superuser', () => { it('should allow all access when enabled at the space ', async () => { const security = { diff --git a/x-pack/plugins/enterprise_search/server/lib/check_access.ts b/x-pack/plugins/enterprise_search/server/lib/check_access.ts index a88eb49d7b02ab..d933b9ac414127 100644 --- a/x-pack/plugins/enterprise_search/server/lib/check_access.ts +++ b/x-pack/plugins/enterprise_search/server/lib/check_access.ts @@ -16,8 +16,8 @@ import { callEnterpriseSearchConfigAPI } from './enterprise_search_config_api'; interface CheckAccess { request: KibanaRequest; - security?: SecurityPluginSetup; - spaces?: SpacesPluginStart; + security: SecurityPluginSetup; + spaces: SpacesPluginStart; config: ConfigType; log: Logger; } @@ -43,21 +43,18 @@ export const checkAccess = async ({ request, log, }: CheckAccess): Promise => { - const isRbacEnabled = security?.authz.mode.useRbacForRequest(request) ?? false; + const isRbacEnabled = security.authz.mode.useRbacForRequest(request); - // We can only retrieve the active space when either: - // 1) security is enabled, and the request has already been authenticated - // 2) security is disabled - const attemptSpaceRetrieval = !isRbacEnabled || request.auth.isAuthenticated; + // If security has been disabled, always hide the plugin + if (!isRbacEnabled) { + return DENY_ALL_PLUGINS; + } - // If we can't retrieve the current space, then assume the feature is available + // We can only retrieve the active space when security is enabled and the request has already been authenticated + const attemptSpaceRetrieval = request.auth.isAuthenticated; let allowedAtSpace = false; - if (!spaces) { - allowedAtSpace = true; - } - - if (spaces && attemptSpaceRetrieval) { + if (attemptSpaceRetrieval) { try { const space = await spaces.spacesService.getActiveSpace(request); allowedAtSpace = !space.disabledFeatures?.includes('enterpriseSearch'); @@ -75,17 +72,12 @@ export const checkAccess = async ({ return DENY_ALL_PLUGINS; } - // If security has been disabled, always show the plugin - if (!isRbacEnabled) { - return ALLOW_ALL_PLUGINS; - } - // If the user is a "superuser" or has the base Kibana all privilege globally, always show the plugin const isSuperUser = async (): Promise => { try { - const { hasAllRequested } = await security!.authz + const { hasAllRequested } = await security.authz .checkPrivilegesWithRequest(request) - .globally({ kibana: security!.authz.actions.ui.get('enterpriseSearch', 'all') }); + .globally({ kibana: security.authz.actions.ui.get('enterpriseSearch', 'all') }); return hasAllRequested; } catch (err) { if (err.statusCode === 401 || err.statusCode === 403) { diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 04bd304ee679f5..63ac8ed02afbe2 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -17,6 +17,7 @@ import { } from '../../../../src/core/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { InfraPluginSetup } from '../../infra/server'; import { SecurityPluginSetup } from '../../security/server'; import { SpacesPluginStart } from '../../spaces/server'; @@ -24,6 +25,7 @@ import { ENTERPRISE_SEARCH_PLUGIN, APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN, + LOGS_SOURCE_ID, } from '../common/constants'; import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; @@ -50,12 +52,13 @@ import { ConfigType } from './'; interface PluginsSetup { usageCollection?: UsageCollectionSetup; - security?: SecurityPluginSetup; + security: SecurityPluginSetup; features: FeaturesPluginSetup; + infra: InfraPluginSetup; } interface PluginsStart { - spaces?: SpacesPluginStart; + spaces: SpacesPluginStart; } export interface RouteDependencies { @@ -77,7 +80,7 @@ export class EnterpriseSearchPlugin implements Plugin { public setup( { capabilities, http, savedObjects, getStartServices }: CoreSetup, - { usageCollection, security, features }: PluginsSetup + { usageCollection, security, features, infra }: PluginsSetup ) { const config = this.config; const log = this.logger; @@ -159,6 +162,18 @@ export class EnterpriseSearchPlugin implements Plugin { } }); registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted }); + + /* + * Register logs source configuration, used by LogStream components + * @see https://github.com/elastic/kibana/blob/master/x-pack/plugins/infra/public/components/log_stream/log_stream.stories.mdx#with-a-source-configuration + */ + infra.defineInternalSourceConfiguration(LOGS_SOURCE_ID, { + name: 'Enterprise Search Logs', + logIndices: { + type: 'index_name', + indexName: '.ent-search-*', + }, + }); } public start() {} diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index 6b4c50770b49f4..976cdfadca4b71 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -16,9 +16,12 @@ "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../cloud/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../security/tsconfig.json" }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx index 33ee95910daa6a..b465ddeee97f86 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/package_policy_input_config.tsx @@ -163,7 +163,7 @@ export const PackagePolicyInputConfig: React.FunctionComponent<{ {isShowingAdvanced ? advancedVars.map((varDef) => { const { name: varName, type: varType } = varDef; - const value = packagePolicyInput.vars![varName].value; + const value = packagePolicyInput.vars?.[varName]?.value; return ( { expect(migration(initialDoc, {} as SavedObjectMigrationContext)).toEqual(migratedDoc); }); + it('adds supported option for ransomware on migrations and linux malware option and notification customization when ransomware is malformed', () => { + const initialDoc = policyDoc({ + windowsMalware: { malware: { mode: 'on' } }, + windowsRansomware: { ransomware: 'off' }, + windowsPopup: { + popup: { + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: true, + }, + }, + }, + }); + + const migratedDoc = policyDoc({ + windowsMalware: { malware: { mode: 'on' } }, + windowsRansomware: { ransomware: { mode: 'off', supported: true } }, + windowsPopup: { + popup: { + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: true, + }, + }, + }, + linuxMalware: { + malware: { + mode: 'on', + }, + }, + linuxPopup: { + popup: { + malware: { + message: '', + enabled: true, + }, + }, + }, + }); + + expect(migration(initialDoc, {} as SavedObjectMigrationContext)).toEqual(migratedDoc); + }); + it('does not modify non-endpoint package policies', () => { const doc: SavedObjectUnsanitizedDoc = { id: 'mock-saved-object-id', diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_14_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_14_0.ts index cd7dcc2d3e1dfc..2f281bcf86a95b 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_14_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/security_solution/to_v7_14_0.ts @@ -31,7 +31,11 @@ export const migrateEndpointPackagePolicyToV7140: SavedObjectMigrationFn< // This value is based on license. // For the migration, we add 'true', our license watcher will correct it, if needed, when the app starts. - policy.windows.ransomware.supported = true; + if (policy?.windows?.ransomware?.mode) { + policy.windows.ransomware.supported = true; + } else { + policy.windows.ransomware = { mode: 'off', supported: true }; + } } } diff --git a/x-pack/plugins/infra/public/index.ts b/x-pack/plugins/infra/public/index.ts index 9c38e5056e398c..929a430286b93f 100644 --- a/x-pack/plugins/infra/public/index.ts +++ b/x-pack/plugins/infra/public/index.ts @@ -30,3 +30,4 @@ export type InfraAppId = 'logs' | 'metrics'; // Shared components export { LazyLogStreamWrapper as LogStream } from './components/log_stream/lazy_log_stream_wrapper'; +export type { LogStreamProps } from './components/log_stream'; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 4cbf609eeea9b4..6d7f9fb09676a9 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -20,6 +20,7 @@ import { LogHighlightsState } from '../../../containers/logs/log_highlights/log_ import { LogPositionState } from '../../../containers/logs/log_position'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; export const LogsToolbar = () => { const { derivedIndexPattern } = useLogSourceContext(); @@ -54,8 +55,8 @@ export const LogsToolbar = () => { return (
- - + + { })} query={filterQueryDraft} /> - + - - - - - - - highlightTerm.length > 0).length > 0 - } - goToPreviousHighlight={goToPreviousHighlight} - goToNextHighlight={goToNextHighlight} - hasPreviousHighlight={hasPreviousHighlight} - hasNextHighlight={hasNextHighlight} - /> + + + + + + + + + highlightTerm.length > 0).length > 0 + } + goToPreviousHighlight={goToPreviousHighlight} + goToNextHighlight={goToNextHighlight} + hasPreviousHighlight={hasPreviousHighlight} + hasNextHighlight={hasNextHighlight} + /> + + {
); }; + +const QueryBarFlexItem = euiStyled(EuiFlexItem)` + @media (min-width: 1200px) { + flex: 0 0 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + padding-left: 12px; + padding-right: 12px; + } +`; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index a2d8e522c7c8d5..5410353ac46a0f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -62,6 +62,7 @@ export const registerMetricInventoryThresholdAlertType = ( * TODO: Remove this use of `any` by utilizing a proper type */ Record, + never, // Only use if defining useSavedObjectReferences hook Record, AlertInstanceState, AlertInstanceContext, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts index 63354111a1a98c..2e3660c901b4a6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts @@ -34,6 +34,7 @@ export const registerMetricAnomalyAlertType = ( * TODO: Remove this use of `any` by utilizing a proper type */ Record, + never, // Only use if defining useSavedObjectReferences hook Record, AlertInstanceState, AlertInstanceContext, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index e519d67b446a5d..9418762d3e1bfd 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -38,6 +38,7 @@ export type MetricThresholdAlertType = AlertType< * TODO: Remove this use of `any` by utilizing a proper type */ Record, + never, // Only use if defining useSavedObjectReferences hook Record, AlertInstanceState, AlertInstanceContext, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/community_id.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/community_id.test.tsx new file mode 100644 index 00000000000000..0338cb8e04348a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/community_id.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue } from './processor.helpers'; + +const COMMUNITY_ID_TYPE = 'community_id'; + +describe('Processor: Community id', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup({ + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + testBed.component.update(); + + // Open flyout to add new processor + testBed.actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await testBed.actions.addProcessorType(COMMUNITY_ID_TYPE); + }); + + test('can submit if no fields are filled', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with no fields filled + await saveNewProcessor(); + + // Expect no form errors + expect(form.getErrorsMessages()).toHaveLength(0); + }); + + test('allows to set either iana_number or transport', async () => { + const { find, form } = testBed; + + expect(find('ianaField.input').exists()).toBe(true); + expect(find('transportField.input').exists()).toBe(true); + + form.setInputValue('ianaField.input', 'iana_number'); + expect(find('transportField.input').props().disabled).toBe(true); + + form.setInputValue('ianaField.input', ''); + form.setInputValue('transportField.input', 'transport'); + expect(find('ianaField.input').props().disabled).toBe(true); + }); + + test('allows optional parameters to be set', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + form.toggleEuiSwitch('ignoreMissingSwitch.input'); + form.toggleEuiSwitch('ignoreFailureSwitch.input'); + form.setInputValue('sourceIpField.input', 'source.ip'); + form.setInputValue('sourcePortField.input', 'source.port'); + form.setInputValue('targetField.input', 'target_field'); + form.setInputValue('destinationIpField.input', 'destination.ip'); + form.setInputValue('destinationPortField.input', 'destination.port'); + form.setInputValue('icmpTypeField.input', 'icmp_type'); + form.setInputValue('icmpCodeField.input', 'icmp_code'); + form.setInputValue('ianaField.input', 'iana'); + form.setInputValue('seedField.input', '10'); + + // Save the field with new changes + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, COMMUNITY_ID_TYPE); + expect(processors[0][COMMUNITY_ID_TYPE]).toEqual({ + ignore_failure: true, + ignore_missing: false, + target_field: 'target_field', + source_ip: 'source.ip', + source_port: 'source.port', + destination_ip: 'destination.ip', + destination_port: 'destination.port', + icmp_type: 'icmp_type', + icmp_code: 'icmp_code', + iana_number: 'iana', + seed: 10, + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index e4024e4ec67f46..183777ca765b43 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -180,4 +180,13 @@ type TestSubject = | 'fieldsValueField.input' | 'saltValueField.input' | 'methodsValueField' + | 'sourceIpField.input' + | 'sourcePortField.input' + | 'destinationIpField.input' + | 'destinationPortField.input' + | 'icmpTypeField.input' + | 'icmpCodeField.input' + | 'ianaField.input' + | 'transportField.input' + | 'seedField.input' | 'trimSwitch.input'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/community_id.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/community_id.tsx new file mode 100644 index 00000000000000..cd6f97d0a299e4 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/community_id.tsx @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCode, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FieldsConfig, from } from './shared'; +import { TargetField } from './common_fields/target_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { + Field, + UseField, + useFormData, + FIELD_TYPES, + NumericField, + SerializerFunc, + fieldFormatters, + fieldValidators, +} from '../../../../../../shared_imports'; + +const SEED_MIN_VALUE = 0; +const SEED_MAX_VALUE = 65535; + +const seedValidator = { + max: fieldValidators.numberSmallerThanField({ + than: SEED_MAX_VALUE, + allowEquality: true, + message: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.seedMaxNumberError', { + defaultMessage: `This number must be equal or less than {maxValue}.`, + values: { maxValue: SEED_MAX_VALUE }, + }), + }), + min: fieldValidators.numberGreaterThanField({ + than: SEED_MIN_VALUE, + allowEquality: true, + message: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.seedMinNumberError', { + defaultMessage: `This number must be equal or greater than {minValue}.`, + values: { minValue: SEED_MIN_VALUE }, + }), + }), +}; + +const fieldsConfig: FieldsConfig = { + source_ip: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.sourceIpLabel', { + defaultMessage: 'Source IP (optional)', + }), + helpText: ( + {'source.ip'} }} + /> + ), + }, + source_port: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.sourcePortLabel', { + defaultMessage: 'Source port (optional)', + }), + helpText: ( + {'source.port'} }} + /> + ), + }, + destination_ip: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.destinationIpLabel', { + defaultMessage: 'Destination IP (optional)', + }), + helpText: ( + {'destination.ip'} }} + /> + ), + }, + destination_port: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.destinationPortLabel', { + defaultMessage: 'Destination port (optional)', + }), + helpText: ( + {'destination.port'} }} + /> + ), + }, + icmp_type: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.icmpTypeLabel', { + defaultMessage: 'ICMP type (optional)', + }), + helpText: ( + {'icmp.type'} }} + /> + ), + }, + icmp_code: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.icmpCodeLabel', { + defaultMessage: 'ICMP code (optional)', + }), + helpText: ( + {'icmp.code'} }} + /> + ), + }, + iana_number: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.ianaLabel', { + defaultMessage: 'IANA number (optional)', + }), + helpText: ( + {'Transport'}, + defaultValue: {'network.iana_number'}, + }} + /> + ), + }, + transport: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.transportLabel', { + defaultMessage: 'Transport (optional)', + }), + helpText: ( + {'IANA number'}, + defaultValue: {'network.transport'}, + }} + /> + ), + }, + seed: { + type: FIELD_TYPES.NUMBER, + formatters: [fieldFormatters.toInt], + serializer: from.undefinedIfValue(''), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.communityId.seedLabel', { + defaultMessage: 'Seed (optional)', + }), + helpText: ( + {'0'} }} + /> + ), + validations: [ + { + validator: (field) => { + if (field.value) { + return seedValidator.max(field) ?? seedValidator.min(field); + } + }, + }, + ], + }, +}; + +export const CommunityId: FunctionComponent = () => { + const [{ fields }] = useFormData({ watch: ['fields.iana_number', 'fields.transport'] }); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {'network.community_id'}, + }} + /> + } + /> + + } + /> + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts index f5eb1ab3ec59be..1a2422b40d0b01 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts @@ -8,6 +8,7 @@ export { Append } from './append'; export { Bytes } from './bytes'; export { Circle } from './circle'; +export { CommunityId } from './community_id'; export { Convert } from './convert'; export { CSV } from './csv'; export { DateProcessor } from './date'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index e6ca465bf1a022..2a7067be512aef 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -14,6 +14,7 @@ import { Append, Bytes, Circle, + CommunityId, Convert, CSV, DateProcessor, @@ -126,6 +127,20 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, }), }, + community_id: { + FieldsComponent: CommunityId, + docLinkPath: '/community-id-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.communityId', { + defaultMessage: 'Community ID', + }), + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.communityId', { + defaultMessage: 'Computes the Community ID for network flow data.', + }), + getDefaultDescription: () => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.communityId', { + defaultMessage: 'Computes the Community ID for network flow data.', + }), + }, convert: { FieldsComponent: Convert, docLinkPath: '/convert-processor.html', diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx new file mode 100644 index 00000000000000..0c494f4d0090d0 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Position } from '@elastic/charts'; +import { shallowWithIntl as shallow, mountWithIntl as mount } from '@kbn/test/jest'; +import { LegendLocationSettings, LegendLocationSettingsProps } from './legend_location_settings'; + +describe('Legend Location Settings', () => { + let props: LegendLocationSettingsProps; + beforeEach(() => { + props = { + onLocationChange: jest.fn(), + onPositionChange: jest.fn(), + }; + }); + + it('should have default the Position to right when no position is given', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-position-btn"]').prop('idSelected') + ).toEqual(Position.Right); + }); + + it('should have called the onPositionChange function on ButtonGroup change', () => { + const component = shallow(); + component.find('[data-test-subj="lens-legend-position-btn"]').simulate('change'); + expect(props.onPositionChange).toHaveBeenCalled(); + }); + + it('should disable the position group if isDisabled prop is true', () => { + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-position-btn"]').prop('isDisabled') + ).toEqual(true); + }); + + it('should hide the position button group if location inside is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect(component.find('[data-test-subj="lens-legend-position-btn"]').length).toEqual(0); + }); + + it('should render the location settings if location inside is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect(component.find('[data-test-subj="lens-legend-location-btn"]').length).toEqual(1); + }); + + it('should have selected the given location', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-location-btn"]').prop('idSelected') + ).toEqual('xy_location_inside'); + }); + + it('should have called the onLocationChange function on ButtonGroup change', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + component + .find('[data-test-subj="lens-legend-location-btn"]') + .simulate('change', 'xy_location_outside'); + expect(props.onLocationChange).toHaveBeenCalled(); + }); + + it('should default the alignment to top right when no value is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-inside-alignment-btn"]').prop('idSelected') + ).toEqual('xy_location_alignment_top_right'); + }); + + it('should have called the onAlignmentChange function on ButtonGroup change', () => { + const newProps = { + ...props, + onAlignmentChange: jest.fn(), + location: 'inside', + } as LegendLocationSettingsProps; + const component = shallow(); + component + .find('[data-test-subj="lens-legend-inside-alignment-btn"]') + .simulate('change', 'xy_location_alignment_top_left'); + expect(newProps.onAlignmentChange).toHaveBeenCalled(); + }); + + it('should have default the columns input to 1 when no value is given', () => { + const newProps = { + ...props, + location: 'inside', + } as LegendLocationSettingsProps; + const component = mount(); + expect( + component.find('[data-test-subj="lens-legend-location-columns-input"]').at(0).prop('value') + ).toEqual(1); + }); + + it('should disable the components when is Disabled is true', () => { + const newProps = { + ...props, + location: 'inside', + isDisabled: true, + } as LegendLocationSettingsProps; + const component = shallow(); + expect( + component.find('[data-test-subj="lens-legend-location-btn"]').prop('isDisabled') + ).toEqual(true); + expect( + component.find('[data-test-subj="lens-legend-inside-alignment-btn"]').prop('isDisabled') + ).toEqual(true); + }); +}); diff --git a/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx new file mode 100644 index 00000000000000..4265dee2251b5e --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/legend_location_settings.tsx @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiButtonGroup, EuiFieldNumber } from '@elastic/eui'; +import { VerticalAlignment, HorizontalAlignment, Position } from '@elastic/charts'; +import { useDebouncedValue } from './debounced_value'; +import { TooltipWrapper } from './tooltip_wrapper'; + +export interface LegendLocationSettingsProps { + /** + * Sets the legend position + */ + position?: Position; + /** + * Callback on position option change + */ + onPositionChange: (id: string) => void; + /** + * Determines the legend location + */ + location?: 'inside' | 'outside'; + /** + * Callback on location option change + */ + onLocationChange?: (id: string) => void; + /** + * Sets the vertical alignment for legend inside chart + */ + verticalAlignment?: VerticalAlignment; + /** + * Sets the vertical alignment for legend inside chart + */ + horizontalAlignment?: HorizontalAlignment; + /** + * Callback on horizontal alignment option change + */ + onAlignmentChange?: (id: string) => void; + /** + * Sets the number of columns for legend inside chart + */ + floatingColumns?: number; + /** + * Callback on horizontal alignment option change + */ + onFloatingColumnsChange?: (value: number) => void; + /** + * Flag to disable the location settings + */ + isDisabled?: boolean; +} + +const DEFAULT_FLOATING_COLUMNS = 1; + +const toggleButtonsIcons = [ + { + id: Position.Top, + label: i18n.translate('xpack.lens.shared.legendPositionTop', { + defaultMessage: 'Top', + }), + iconType: 'arrowUp', + }, + { + id: Position.Right, + label: i18n.translate('xpack.lens.shared.legendPositionRight', { + defaultMessage: 'Right', + }), + iconType: 'arrowRight', + }, + { + id: Position.Bottom, + label: i18n.translate('xpack.lens.shared.legendPositionBottom', { + defaultMessage: 'Bottom', + }), + iconType: 'arrowDown', + }, + { + id: Position.Left, + label: i18n.translate('xpack.lens.shared.legendPositionLeft', { + defaultMessage: 'Left', + }), + iconType: 'arrowLeft', + }, +]; + +const locationOptions: Array<{ + id: string; + value: 'outside' | 'inside'; + label: string; +}> = [ + { + id: `xy_location_outside`, + value: 'outside', + label: i18n.translate('xpack.lens.xyChart.legendLocation.outside', { + defaultMessage: 'Outside', + }), + }, + { + id: `xy_location_inside`, + value: 'inside', + label: i18n.translate('xpack.lens.xyChart.legendLocation.inside', { + defaultMessage: 'Inside', + }), + }, +]; + +const locationAlignmentButtonsIcons: Array<{ + id: string; + value: 'bottom_left' | 'bottom_right' | 'top_left' | 'top_right'; + label: string; + iconType: string; +}> = [ + { + id: 'xy_location_alignment_top_right', + value: 'top_right', + label: i18n.translate('xpack.lens.shared.legendLocationTopRight', { + defaultMessage: 'Top right', + }), + iconType: 'editorPositionTopRight', + }, + { + id: 'xy_location_alignment_top_left', + value: 'top_left', + label: i18n.translate('xpack.lens.shared.legendLocationTopLeft', { + defaultMessage: 'Top left', + }), + iconType: 'editorPositionTopLeft', + }, + { + id: 'xy_location_alignment_bottom_right', + value: 'bottom_right', + label: i18n.translate('xpack.lens.shared.legendLocationBottomRight', { + defaultMessage: 'Bottom right', + }), + iconType: 'editorPositionBottomRight', + }, + { + id: 'xy_location_alignment_bottom_left', + value: 'bottom_left', + label: i18n.translate('xpack.lens.shared.legendLocationBottomLeft', { + defaultMessage: 'Bottom left', + }), + iconType: 'editorPositionBottomLeft', + }, +]; + +const FloatingColumnsInput = ({ + value, + setValue, + isDisabled, +}: { + value: number; + setValue: (value: number) => void; + isDisabled: boolean; +}) => { + const { inputValue, handleInputChange } = useDebouncedValue({ value, onChange: setValue }); + return ( + { + handleInputChange(Number(e.target.value)); + }} + /> + ); +}; + +export const LegendLocationSettings: React.FunctionComponent = ({ + location, + onLocationChange = () => {}, + position, + onPositionChange, + verticalAlignment, + horizontalAlignment, + onAlignmentChange = () => {}, + floatingColumns, + onFloatingColumnsChange = () => {}, + isDisabled = false, +}) => { + const alignment = `${verticalAlignment || VerticalAlignment.Top}_${ + horizontalAlignment || HorizontalAlignment.Right + }`; + return ( + <> + {location && ( + + + value === location)!.id} + onChange={(optionId) => { + const newLocation = locationOptions.find(({ id }) => id === optionId)!.value; + onLocationChange(newLocation); + }} + /> + + + )} + + <> + {(!location || location === 'outside') && ( + + + + )} + {location === 'inside' && ( + + value === alignment)!.id + } + onChange={(optionId) => { + const newAlignment = locationAlignmentButtonsIcons.find( + ({ id }) => id === optionId + )!.value; + onAlignmentChange(newAlignment); + }} + isIconOnly + /> + + )} + + + {location && ( + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx index 5a6f1b91234e8e..e2fd630702b6be 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { Position } from '@elastic/charts'; import { shallowWithIntl as shallow } from '@kbn/test/jest'; import { LegendSettingsPopover, LegendSettingsPopoverProps } from './legend_settings_popover'; @@ -51,26 +50,6 @@ describe('Legend Settings', () => { expect(props.onDisplayChange).toHaveBeenCalled(); }); - it('should have default the Position to right when no position is given', () => { - const component = shallow(); - expect( - component.find('[data-test-subj="lens-legend-position-btn"]').prop('idSelected') - ).toEqual(Position.Right); - }); - - it('should have called the onPositionChange function on ButtonGroup change', () => { - const component = shallow(); - component.find('[data-test-subj="lens-legend-position-btn"]').simulate('change'); - expect(props.onPositionChange).toHaveBeenCalled(); - }); - - it('should disable the position button group on hide mode', () => { - const component = shallow(); - expect( - component.find('[data-test-subj="lens-legend-position-btn"]').prop('isDisabled') - ).toEqual(true); - }); - it('should enable the Nested Legend Switch when renderNestedLegendSwitch prop is true', () => { const component = shallow(); expect(component.find('[data-test-subj="lens-legend-nested-switch"]')).toHaveLength(1); diff --git a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx index e86a81ba662035..0ec7c11f6fdc1d 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_settings_popover.tsx @@ -8,15 +8,21 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiButtonGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; -import { Position } from '@elastic/charts'; +import { Position, VerticalAlignment, HorizontalAlignment } from '@elastic/charts'; import { ToolbarPopover } from '../shared_components'; +import { LegendLocationSettings } from './legend_location_settings'; import { ToolbarButtonProps } from '../../../../../src/plugins/kibana_react/public'; +import { TooltipWrapper } from './tooltip_wrapper'; export interface LegendSettingsPopoverProps { /** * Determines the legend display options */ - legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide' | 'default'; label: string }>; + legendOptions: Array<{ + id: string; + value: 'auto' | 'show' | 'hide' | 'default'; + label: string; + }>; /** * Determines the legend mode */ @@ -33,6 +39,34 @@ export interface LegendSettingsPopoverProps { * Callback on position option change */ onPositionChange: (id: string) => void; + /** + * Determines the legend location + */ + location?: 'inside' | 'outside'; + /** + * Callback on location option change + */ + onLocationChange?: (id: string) => void; + /** + * Sets the vertical alignment for legend inside chart + */ + verticalAlignment?: VerticalAlignment; + /** + * Sets the vertical alignment for legend inside chart + */ + horizontalAlignment?: HorizontalAlignment; + /** + * Callback on horizontal alignment option change + */ + onAlignmentChange?: (id: string) => void; + /** + * Sets the number of columns for legend inside chart + */ + floatingColumns?: number; + /** + * Callback on horizontal alignment option change + */ + onFloatingColumnsChange?: (value: number) => void; /** * If true, nested legend switch is rendered */ @@ -63,42 +97,18 @@ export interface LegendSettingsPopoverProps { groupPosition?: ToolbarButtonProps['groupPosition']; } -const toggleButtonsIcons = [ - { - id: Position.Bottom, - label: i18n.translate('xpack.lens.shared.legendPositionBottom', { - defaultMessage: 'Bottom', - }), - iconType: 'arrowDown', - }, - { - id: Position.Left, - label: i18n.translate('xpack.lens.shared.legendPositionLeft', { - defaultMessage: 'Left', - }), - iconType: 'arrowLeft', - }, - { - id: Position.Right, - label: i18n.translate('xpack.lens.shared.legendPositionRight', { - defaultMessage: 'Right', - }), - iconType: 'arrowRight', - }, - { - id: Position.Top, - label: i18n.translate('xpack.lens.shared.legendPositionTop', { - defaultMessage: 'Top', - }), - iconType: 'arrowUp', - }, -]; - export const LegendSettingsPopover: React.FunctionComponent = ({ legendOptions, mode, onDisplayChange, position, + location, + onLocationChange = () => {}, + verticalAlignment, + horizontalAlignment, + floatingColumns, + onAlignmentChange = () => {}, + onFloatingColumnsChange = () => {}, onPositionChange, renderNestedLegendSwitch, nestedLegend, @@ -136,26 +146,18 @@ export const LegendSettingsPopover: React.FunctionComponent - - - + {renderNestedLegendSwitch && ( - + condition={mode === 'hide'} + position="top" + delay="regular" + display="block" + > + + )} {renderValueInLegendSwitch && ( @@ -183,17 +195,27 @@ export const LegendSettingsPopover: React.FunctionComponent - + condition={mode === 'hide'} + position="top" + delay="regular" + display="block" + > + + )} diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index ac8f089d46487f..bcf54c6696ee03 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -114,6 +114,9 @@ Object { "chain": Array [ Object { "arguments": Object { + "floatingColumns": Array [], + "horizontalAlignment": Array [], + "isInside": Array [], "isVisible": Array [ true, ], @@ -121,6 +124,7 @@ Object { "bottom", ], "showSingleSeries": Array [], + "verticalAlignment": Array [], }, "function": "lens_xy_legendConfig", "type": "function", diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx index 930f6888ce5320..b018e62f1fd8f7 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.test.tsx @@ -17,6 +17,9 @@ import { XYChartSeriesIdentifier, SeriesNameFn, Fit, + HorizontalAlignment, + VerticalAlignment, + LayoutDirection, } from '@elastic/charts'; import { PaletteOutput } from 'src/plugins/charts/public'; import { @@ -2251,6 +2254,30 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('showLegend')).toEqual(true); }); + test('it should populate the correct legendPosition if isInside is set', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + + expect(component.find(Settings).prop('legendPosition')).toEqual({ + vAlign: VerticalAlignment.Top, + hAlign: HorizontalAlignment.Right, + direction: LayoutDirection.Vertical, + floating: true, + floatingColumns: 1, + }); + }); + test('it not show legend if isVisible is set to false', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 404608f9da43ae..7c767cd1d1b04d 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -22,9 +22,11 @@ import { StackMode, VerticalAlignment, HorizontalAlignment, + LayoutDirection, ElementClickListener, BrushEndListener, CurveType, + LegendPositionConfig, LabelOverflowConstraint, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; @@ -602,6 +604,14 @@ export function XYChart({ onSelectRange(context); }; + const legendInsideParams = { + vAlign: legend.verticalAlignment ?? VerticalAlignment.Top, + hAlign: legend?.horizontalAlignment ?? HorizontalAlignment.Right, + direction: LayoutDirection.Vertical, + floating: true, + floatingColumns: legend?.floatingColumns ?? 1, + } as LegendPositionConfig; + return ( , index: n }; } -const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ +const legendOptions: Array<{ + id: string; + value: 'auto' | 'show' | 'hide'; + label: string; +}> = [ { id: `xy_legend_auto`, value: 'auto', @@ -319,32 +323,72 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp { + setState({ + ...state, + legend: { + ...state.legend, + isInside: location === 'inside', + }, + }); + }} onDisplayChange={(optionId) => { const newMode = legendOptions.find(({ id }) => id === optionId)!.value; if (newMode === 'auto') { setState({ ...state, - legend: { ...state.legend, isVisible: true, showSingleSeries: false }, + legend: { + ...state.legend, + isVisible: true, + showSingleSeries: false, + }, }); } else if (newMode === 'show') { setState({ ...state, - legend: { ...state.legend, isVisible: true, showSingleSeries: true }, + legend: { + ...state.legend, + isVisible: true, + showSingleSeries: true, + }, }); } else if (newMode === 'hide') { setState({ ...state, - legend: { ...state.legend, isVisible: false, showSingleSeries: false }, + legend: { + ...state.legend, + isVisible: false, + showSingleSeries: false, + }, }); } }} position={state?.legend.position} + horizontalAlignment={state?.legend.horizontalAlignment} + verticalAlignment={state?.legend.verticalAlignment} + floatingColumns={state?.legend.floatingColumns} + onFloatingColumnsChange={(val) => { + setState({ + ...state, + legend: { ...state.legend, floatingColumns: val }, + }); + }} onPositionChange={(id) => { setState({ ...state, legend: { ...state.legend, position: id as Position }, }); }} + onAlignmentChange={(value) => { + const [vertical, horizontal] = value.split('_'); + const verticalAlignment = vertical as VerticalAlignment; + const horizontalAlignment = horizontal as HorizontalAlignment; + setState({ + ...state, + legend: { ...state.legend, verticalAlignment, horizontalAlignment }, + }); + }} renderValueInLegendSwitch={nonOrdinalXAxis} valueInLegend={state?.valuesInLegend} onValueInLegendChange={() => { diff --git a/x-pack/plugins/lists/server/routes/read_privileges_route.ts b/x-pack/plugins/lists/server/routes/read_privileges_route.ts index e0806a9f8f82ec..0fc72e6cc4341f 100644 --- a/x-pack/plugins/lists/server/routes/read_privileges_route.ts +++ b/x-pack/plugins/lists/server/routes/read_privileges_route.ts @@ -25,16 +25,10 @@ export const readPrivilegesRoute = (router: ListsPluginRouter): void => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; const lists = getListClient(context); - const clusterPrivilegesLists = await readPrivileges( - clusterClient.callAsCurrentUser, - lists.getListIndex() - ); - const clusterPrivilegesListItems = await readPrivileges( - clusterClient.callAsCurrentUser, - lists.getListItemIndex() - ); + const clusterPrivilegesLists = await readPrivileges(esClient, lists.getListIndex()); + const clusterPrivilegesListItems = await readPrivileges(esClient, lists.getListItemIndex()); const privileges = merge( { listItems: clusterPrivilegesListItems, diff --git a/x-pack/plugins/lists/server/schemas/common/get_call_cluster.mock.ts b/x-pack/plugins/lists/server/schemas/common/get_call_cluster.mock.ts deleted file mode 100644 index 5ed745c3dbc90b..00000000000000 --- a/x-pack/plugins/lists/server/schemas/common/get_call_cluster.mock.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CreateDocumentResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; - -import { LIST_INDEX } from '../../../common/constants.mock'; - -import { getShardMock } from './get_shard.mock'; - -export const getEmptyCreateDocumentResponseMock = (): CreateDocumentResponse => ({ - _id: 'elastic-id-123', - _index: LIST_INDEX, - _shards: getShardMock(), - _type: '', - _version: 1, - created: true, - result: '', -}); - -export const getCallClusterMock = ( - response: unknown = getEmptyCreateDocumentResponseMock() -): LegacyAPICaller => jest.fn().mockResolvedValue(response); - -export const getCallClusterMockMultiTimes = ( - responses: unknown[] = [getEmptyCreateDocumentResponseMock()] -): LegacyAPICaller => { - const returnJest = jest.fn(); - responses.forEach((response) => { - returnJest.mockResolvedValueOnce(response); - }); - return returnJest; -}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_view/detector_description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_view/detector_description.tsx index dcd6d859ff868e..38e52d3f1b5e84 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_view/detector_description.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/rare_view/detector_description.tsx @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, useContext, useEffect, useState } from 'react'; import { EuiCallOut } from '@elastic/eui'; @@ -21,7 +20,7 @@ interface Props { export const DetectorDescription: FC = ({ detectorType }) => { const { jobCreator: jc, jobCreatorUpdated } = useContext(JobCreatorContext); const jobCreator = jc as RareJobCreator; - const [description, setDescription] = useState(null); + const [description, setDescription] = useState(null); useEffect(() => { const desc = createDetectorDescription(jobCreator, detectorType); @@ -37,18 +36,12 @@ export const DetectorDescription: FC = ({ detectorType }) => { title={i18n.translate( 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.calloutTitle', { - defaultMessage: 'Detector summary', + defaultMessage: 'Job summary', } )} > -
    - {description.map((d) => ( -
  • {d}
  • - ))} +
  • {description}
); @@ -63,50 +56,71 @@ function createDetectorDescription(jobCreator: RareJobCreator, detectorType: RAR const populationFieldName = jobCreator.populationField?.id; const splitFieldName = jobCreator.splitField?.id; - const beginningSummary = i18n.translate( - 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.beginningSummary', + const rareSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.rareSummary', { - defaultMessage: 'detects rare values of {rareFieldName}', + defaultMessage: 'Detects rare {rareFieldName} values.', values: { rareFieldName }, } ); - const beginningSummaryFreq = i18n.translate( - 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.beginningSummaryFreq', + const rareSplitSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.rareSplitSummary', { - defaultMessage: 'detects frequently rare values of {rareFieldName}', - values: { rareFieldName }, + defaultMessage: 'For each {splitFieldName}, detects rare {rareFieldName} values.', + values: { splitFieldName, rareFieldName }, } ); - const population = i18n.translate( - 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.population', + const freqRarePopulationSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.freqRarePopulationSummary', { - defaultMessage: 'compared to the population of {populationFieldName}', - values: { populationFieldName }, + defaultMessage: + 'Detects {populationFieldName} values that frequently have rare {rareFieldName} values relative to the population.', + values: { populationFieldName, rareFieldName }, } ); - const split = i18n.translate('xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.split', { - defaultMessage: 'for each value of {splitFieldName}', - values: { splitFieldName }, - }); + const freqRareSplitPopulationSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.freqRareSplitPopulationSummary', + { + defaultMessage: + 'For each {splitFieldName}, detects {populationFieldName} values that frequently have rare {rareFieldName} values relative to the population.', + values: { splitFieldName, populationFieldName, rareFieldName }, + } + ); - const desc = []; + const rarePopulationSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.rarePopulationSummary', + { + defaultMessage: + 'Detects {populationFieldName} values that have rare {rareFieldName} values relative to the population.', + values: { populationFieldName, rareFieldName }, + } + ); - if (detectorType === RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION) { - desc.push(beginningSummaryFreq); - } else { - desc.push(beginningSummary); + const rareSplitPopulationSummary = i18n.translate( + 'xpack.ml.newJob.wizard.pickFieldsStep.rareField.plainText.rareSplitPopulationSummary', + { + defaultMessage: + 'For each {splitFieldName}, detects {populationFieldName} values that have rare {rareFieldName} values relative to the population.', + values: { splitFieldName, populationFieldName, rareFieldName }, + } + ); + + if (detectorType === RARE_DETECTOR_TYPE.RARE) { + return splitFieldName !== undefined ? rareSplitSummary : rareSummary; } - if (populationFieldName !== undefined) { - desc.push(population); + if (detectorType === RARE_DETECTOR_TYPE.FREQ_RARE_POPULATION) { + return splitFieldName !== undefined + ? freqRareSplitPopulationSummary + : freqRarePopulationSummary; } - if (splitFieldName !== undefined) { - desc.push(split); + if (detectorType === RARE_DETECTOR_TYPE.RARE_POPULATION) { + return splitFieldName !== undefined ? rareSplitPopulationSummary : rarePopulationSummary; } - return desc; + return null; } diff --git a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts index 93c627c0f6311f..07bca8f3aae745 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts @@ -46,6 +46,7 @@ export function registerAnomalyDetectionAlertType({ }: RegisterAlertParams) { alerting.registerType< MlAnomalyDetectionAlertParams, + never, // Only use if defining useSavedObjectReferences hook AlertTypeState, AlertInstanceState, AnomalyDetectionAlertContext, diff --git a/x-pack/plugins/monitoring/server/alerts/base_alert.ts b/x-pack/plugins/monitoring/server/alerts/base_alert.ts index cfd7630a65dbc4..0020ef779838f9 100644 --- a/x-pack/plugins/monitoring/server/alerts/base_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/base_alert.ts @@ -81,7 +81,7 @@ export class BaseAlert { this.scopedLogger = Globals.app.getLogger(alertOptions.id); } - public getAlertType(): AlertType { + public getAlertType(): AlertType { const { id, name, actionVariables } = this.alertOptions; return { id, diff --git a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx index bf4c97d63d74ca..75277059bbf970 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx @@ -53,6 +53,18 @@ const ActionResultsSummaryComponent: React.FC = ({ sortField: '@timestamp', isLive, }); + if (expired) { + // @ts-expect-error update types + edges.forEach((edge) => { + if (!edge.fields.completed_at) { + edge.fields['error.keyword'] = edge.fields.error = [ + i18n.translate('xpack.osquery.liveQueryActionResults.table.expiredErrorText', { + defaultMessage: 'The action request timed out.', + }), + ]; + } + }); + } const { data: logsResults } = useAllResults({ actionId, diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts index cda15cc8054371..fac43eaa7ffc3f 100644 --- a/x-pack/plugins/osquery/public/agents/use_all_agents.ts +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -34,8 +34,7 @@ export const useAllAgents = ( const { isLoading: agentsLoading, data: agentData } = useQuery( ['agents', osqueryPolicies, searchValue, perPage], () => { - const policyFragment = osqueryPolicies.map((p) => `policy_id:${p}`).join(' or '); - let kuery = `last_checkin_status: online and (${policyFragment})`; + let kuery = `${osqueryPolicies.map((p) => `policy_id:${p}`).join(' or ')}`; if (searchValue) { kuery += ` and (local_metadata.host.hostname:*${searchValue}* or local_metadata.elastic.agent.id:*${searchValue}*)`; diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx index 8738c06d065979..0c04e816dae7a1 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/list/index.tsx @@ -55,8 +55,8 @@ const SavedQueriesPageComponent = () => { const { push } = useHistory(); const newQueryLinkProps = useRouterNavigate('saved_queries/new'); const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [sortField, setSortField] = useState('updated_at'); + const [pageSize, setPageSize] = useState(20); + const [sortField, setSortField] = useState('attributes.updated_at'); const [sortDirection, setSortDirection] = useState('desc'); const { data } = useSavedQueries({ isLive: true }); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx index 6417b40747e0f1..8cfceec643bac3 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx @@ -9,9 +9,11 @@ import { isArray } from 'lodash'; import uuid from 'uuid'; import { produce } from 'immer'; +import { useMemo } from 'react'; import { useForm } from '../../shared_imports'; -import { formSchema } from '../../scheduled_query_groups/queries/schema'; +import { createFormSchema } from '../../scheduled_query_groups/queries/schema'; import { ScheduledQueryGroupFormData } from '../../scheduled_query_groups/queries/use_scheduled_query_group_query_form'; +import { useSavedQueries } from '../use_saved_queries'; const SAVED_QUERY_FORM_ID = 'savedQueryForm'; @@ -20,11 +22,29 @@ interface UseSavedQueryFormProps { handleSubmit: (payload: unknown) => Promise; } -export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) => - useForm({ +export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryFormProps) => { + const { data } = useSavedQueries({}); + const ids: string[] = useMemo( + () => data?.savedObjects.map((obj) => obj.attributes.id) ?? [], + [data] + ); + const idSet = useMemo>(() => { + const res = new Set(ids); + // @ts-expect-error update types + if (defaultValue && defaultValue.id) res.delete(defaultValue.id); + return res; + }, [ids, defaultValue]); + const formSchema = useMemo>(() => createFormSchema(idSet), [ + idSet, + ]); + return useForm({ id: SAVED_QUERY_FORM_ID + uuid.v4(), schema: formSchema, - onSubmit: handleSubmit, + onSubmit: async (formData, isValid) => { + if (isValid) { + return handleSubmit(formData); + } + }, options: { stripEmptyFields: false, }, @@ -62,3 +82,4 @@ export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryF }; }, }); +}; diff --git a/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts index cc5c33c6e42801..1d10d80bd6fbf2 100644 --- a/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts +++ b/x-pack/plugins/osquery/public/saved_queries/use_create_saved_query.ts @@ -37,6 +37,16 @@ export const useCreateSavedQuery = ({ withRedirect }: UseCreateSavedQueryProps) throw new Error('CurrentUser is missing'); } + const conflictingEntries = await savedObjects.client.find({ + type: savedQuerySavedObjectType, + // @ts-expect-error update types + search: payload.id, + searchFields: ['id'], + }); + if (conflictingEntries.savedObjects.length) { + // @ts-expect-error update types + throw new Error(`Saved query with id ${payload.id} already exists.`); + } return savedObjects.client.create(savedQuerySavedObjectType, { // @ts-expect-error update types ...payload, diff --git a/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts b/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts index 6f4aa517108112..fe0d38648b23c3 100644 --- a/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts +++ b/x-pack/plugins/osquery/public/saved_queries/use_update_saved_query.ts @@ -37,6 +37,17 @@ export const useUpdateSavedQuery = ({ savedQueryId }: UseUpdateSavedQueryProps) throw new Error('CurrentUser is missing'); } + const conflictingEntries = await savedObjects.client.find({ + type: savedQuerySavedObjectType, + // @ts-expect-error update types + search: payload.id, + searchFields: ['id'], + }); + if (conflictingEntries.savedObjects.length) { + // @ts-expect-error update types + throw new Error(`Saved query with id ${payload.id} already exists.`); + } + return savedObjects.client.update(savedQuerySavedObjectType, savedQueryId, { // @ts-expect-error update types ...payload, diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx index 0718ff028e0022..46b4a9a72f7ee1 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/form/queries_field.tsx @@ -216,6 +216,20 @@ const QueriesFieldComponent: React.FC = ({ field.value, ]); + const uniqueQueryIds = useMemo( + () => + field.value && field.value[0].streams.length + ? field.value[0].streams.reduce((acc, stream) => { + if (stream.vars?.id.value) { + acc.push(stream.vars?.id.value); + } + + return acc; + }, [] as string[]) + : [], + [field.value] + ); + return ( <> @@ -256,6 +270,7 @@ const QueriesFieldComponent: React.FC = ({ {} {showAddQueryFlyout && ( = ({ )} {showEditQueryFlyout != null && showEditQueryFlyout >= 0 && ( Promise; @@ -47,6 +48,7 @@ interface QueryFlyoutProps { } const QueryFlyoutComponent: React.FC = ({ + uniqueQueryIds, defaultValue, integrationPackageVersion, onSave, @@ -54,6 +56,7 @@ const QueryFlyoutComponent: React.FC = ({ }) => { const [isEditMode] = useState(!!defaultValue); const { form } = useScheduledQueryGroupQueryForm({ + uniqueQueryIds, defaultValue, handleSubmit: (payload, isValid) => new Promise((resolve) => { @@ -65,7 +68,7 @@ const QueryFlyoutComponent: React.FC = ({ }), }); - /* Platform and version fields are supported since osquer_manger@0.3.0 */ + /* Platform and version fields are supported since osquery_manager@0.3.0 */ const isFieldSupported = useMemo( () => (integrationPackageVersion ? satisfies(integrationPackageVersion, '>=0.3.0') : false), [integrationPackageVersion] diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx index 0b23ce924f9301..3eb299cf5fa152 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx @@ -12,15 +12,19 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FIELD_TYPES } from '../../shared_imports'; -import { idFieldValidations, intervalFieldValidation, queryFieldValidation } from './validations'; +import { + createIdFieldValidations, + intervalFieldValidation, + queryFieldValidation, +} from './validations'; -export const formSchema = { +export const createFormSchema = (ids: Set) => ({ id: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.idFieldLabel', { defaultMessage: 'ID', }), - validations: idFieldValidations.map((validator) => ({ validator })), + validations: createIdFieldValidations(ids).map((validator) => ({ validator })), }, description: { type: FIELD_TYPES.TEXT, @@ -69,4 +73,4 @@ export const formSchema = { ) as unknown) as string, validations: [], }, -}; +}); diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx index fdf781c6d6f7ac..67361e612b094b 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/use_scheduled_query_group_query_form.tsx @@ -5,17 +5,19 @@ * 2.0. */ -import { isArray } from 'lodash'; +import { isArray, xor } from 'lodash'; import uuid from 'uuid'; import { produce } from 'immer'; +import { useMemo } from 'react'; import { FormConfig, useForm } from '../../shared_imports'; import { OsqueryManagerPackagePolicyConfigRecord } from '../../../common/types'; -import { formSchema } from './schema'; +import { createFormSchema } from './schema'; const FORM_ID = 'editQueryFlyoutForm'; export interface UseScheduledQueryGroupQueryFormProps { + uniqueQueryIds: string[]; defaultValue?: OsqueryManagerPackagePolicyConfigRecord | undefined; handleSubmit: FormConfig< OsqueryManagerPackagePolicyConfigRecord, @@ -32,12 +34,26 @@ export interface ScheduledQueryGroupFormData { } export const useScheduledQueryGroupQueryForm = ({ + uniqueQueryIds, defaultValue, handleSubmit, -}: UseScheduledQueryGroupQueryFormProps) => - useForm({ +}: UseScheduledQueryGroupQueryFormProps) => { + const idSet = useMemo>( + () => + new Set(xor(uniqueQueryIds, defaultValue?.id.value ? [defaultValue.id.value] : [])), + [uniqueQueryIds, defaultValue] + ); + const formSchema = useMemo>(() => createFormSchema(idSet), [ + idSet, + ]); + + return useForm({ id: FORM_ID + uuid.v4(), - onSubmit: handleSubmit, + onSubmit: async (formData, isValid) => { + if (isValid && handleSubmit) { + return handleSubmit(formData, isValid); + } + }, options: { stripEmptyFields: false, }, @@ -75,3 +91,4 @@ export const useScheduledQueryGroupQueryForm = ({ }, schema: formSchema, }); +}; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts index 95e3000476a081..c9f128b8e5d794 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/validations.ts @@ -23,13 +23,28 @@ const idSchemaValidation: ValidationFunc = ({ value }) => { } }; -export const idFieldValidations = [ +const createUniqueIdValidation = (ids: Set) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const uniqueIdCheck: ValidationFunc = ({ value }) => { + if (ids.has(value)) { + return { + message: i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.uniqueIdError', { + defaultMessage: 'ID must be unique', + }), + }; + } + }; + return uniqueIdCheck; +}; + +export const createIdFieldValidations = (ids: Set) => [ fieldValidators.emptyField( i18n.translate('xpack.osquery.scheduledQueryGroup.queryFlyoutForm.emptyIdError', { defaultMessage: 'ID is required', }) ), idSchemaValidation, + createUniqueIdValidation(ids), ]; export const intervalFieldValidation: ValidationFunc< diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts index 93d552b3f71f3a..30adbb6cfa4ee9 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/use_scheduled_query_group.ts @@ -31,7 +31,7 @@ export const useScheduledQueryGroup = ({ () => http.get(packagePolicyRouteService.getInfoPath(scheduledQueryGroupId)), { keepPreviousData: true, - enabled: !skip, + enabled: !skip || !scheduledQueryGroupId, select: (response) => response.item, } ); diff --git a/x-pack/plugins/osquery/server/routes/usage/recorder.test.ts b/x-pack/plugins/osquery/server/routes/usage/recorder.test.ts index aa5f550234fcb0..ae93f08d76bd5d 100644 --- a/x-pack/plugins/osquery/server/routes/usage/recorder.test.ts +++ b/x-pack/plugins/osquery/server/routes/usage/recorder.test.ts @@ -11,7 +11,7 @@ import { usageMetricSavedObjectType } from '../../../common/types'; import { CounterValue, - createMetricObjects, + getOrCreateMetricObject, getRouteMetric, incrementCount, RouteString, @@ -45,31 +45,22 @@ describe('Usage metric recorder', () => { get.mockClear(); create.mockClear(); }); - it('should seed route metrics objects', async () => { + it('should create metrics that do not exist', async () => { get.mockRejectedValueOnce('stub value'); create.mockReturnValueOnce('stub value'); - const result = await createMetricObjects(savedObjectsClient); + const result = await getOrCreateMetricObject(savedObjectsClient, 'live_query'); checkGetCalls(get.mock.calls); checkCreateCalls(create.mock.calls); - expect(result).toBe(true); + expect(result).toBe('stub value'); }); - it('should handle previously seeded objects properly', async () => { + it('should handle previously created objects properly', async () => { get.mockReturnValueOnce('stub value'); create.mockRejectedValueOnce('stub value'); - const result = await createMetricObjects(savedObjectsClient); + const result = await getOrCreateMetricObject(savedObjectsClient, 'live_query'); checkGetCalls(get.mock.calls); checkCreateCalls(create.mock.calls, []); - expect(result).toBe(true); - }); - - it('should report failure to create the metrics object', async () => { - get.mockRejectedValueOnce('stub value'); - create.mockRejectedValueOnce('stub value'); - const result = await createMetricObjects(savedObjectsClient); - checkGetCalls(get.mock.calls); - checkCreateCalls(create.mock.calls); - expect(result).toBe(false); + expect(result).toBe('stub value'); }); }); diff --git a/x-pack/plugins/osquery/server/routes/usage/recorder.ts b/x-pack/plugins/osquery/server/routes/usage/recorder.ts index 9f5e7cd1d56e0a..cd374b90209799 100644 --- a/x-pack/plugins/osquery/server/routes/usage/recorder.ts +++ b/x-pack/plugins/osquery/server/routes/usage/recorder.ts @@ -18,30 +18,28 @@ export type RouteString = 'live_query'; export const routeStrings: RouteString[] = ['live_query']; -export async function createMetricObjects(soClient: SavedObjectsClientContract) { - const res = await Promise.allSettled( - routeStrings.map(async (route) => { - try { - await soClient.get(usageMetricSavedObjectType, route); - } catch (e) { - await soClient.create( - usageMetricSavedObjectType, - { - errors: 0, - count: 0, - }, - { - id: route, - } - ); +export async function getOrCreateMetricObject( + soClient: SavedObjectsClientContract, + route: string +) { + try { + return await soClient.get(usageMetricSavedObjectType, route); + } catch (e) { + return await soClient.create( + usageMetricSavedObjectType, + { + errors: 0, + count: 0, + }, + { + id: route, } - }) - ); - return !res.some((e) => e.status === 'rejected'); + ); + } } export async function getCount(soClient: SavedObjectsClientContract, route: RouteString) { - return await soClient.get(usageMetricSavedObjectType, route); + return await getOrCreateMetricObject(soClient, route); } export interface CounterValue { @@ -55,7 +53,7 @@ export async function incrementCount( key: keyof CounterValue = 'count', increment = 1 ) { - const metric = await soClient.get(usageMetricSavedObjectType, route); + const metric = await getOrCreateMetricObject(soClient, route); metric.attributes[key] += increment; await soClient.update(usageMetricSavedObjectType, route, metric.attributes); } diff --git a/x-pack/plugins/osquery/server/usage/collector.ts b/x-pack/plugins/osquery/server/usage/collector.ts index 9b690be6df0f16..4432592a4e0635 100644 --- a/x-pack/plugins/osquery/server/usage/collector.ts +++ b/x-pack/plugins/osquery/server/usage/collector.ts @@ -7,7 +7,6 @@ import { CoreSetup, SavedObjectsClient } from '../../../../../src/core/server'; import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; -import { createMetricObjects } from '../routes/usage'; import { getBeatUsage, getLiveQueryUsage, getPolicyLevelUsage } from './fetchers'; import { CollectorDependencies, usageSchema, UsageData } from './types'; @@ -25,10 +24,7 @@ export const registerCollector: RegisterCollector = ({ core, osqueryContext, usa const collector = usageCollection.makeUsageCollector({ type: 'osquery', schema: usageSchema, - isReady: async () => { - const savedObjectsClient = new SavedObjectsClient(await getInternalSavedObjectsClient(core)); - return await createMetricObjects(savedObjectsClient); - }, + isReady: () => true, fetch: async ({ esClient }: CollectorFetchContext): Promise => { const savedObjectsClient = new SavedObjectsClient(await getInternalSavedObjectsClient(core)); return { diff --git a/x-pack/plugins/osquery/server/usage/fetchers.ts b/x-pack/plugins/osquery/server/usage/fetchers.ts index 3d5f3592101fd2..3ac7d56acac4d3 100644 --- a/x-pack/plugins/osquery/server/usage/fetchers.ts +++ b/x-pack/plugins/osquery/server/usage/fetchers.ts @@ -45,6 +45,11 @@ export async function getPolicyLevelUsage( const agentResponse = await esClient.search({ body: { size: 0, + query: { + match: { + active: true, + }, + }, aggs: { policied: { filter: { @@ -87,7 +92,8 @@ export function getScheduledQueryUsage(packagePolicies: ListResult { ++acc.queryGroups.total; - if (item.inputs.length === 0) { + const policyAgents = item.inputs.reduce((sum, input) => sum + input.streams.length, 0); + if (policyAgents === 0) { ++acc.queryGroups.empty; } return acc; diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index fcbc4662c6e593..44ecc01bd1eb32 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -158,7 +158,11 @@ export class ReportingPublicPlugin getStartServices(), import('./management/mount_management_section'), ]); - return await mountManagementSection( + const { + chrome: { docTitle }, + } = start; + docTitle.change(this.title); + const umountAppCallback = await mountManagementSection( core, start, license$, @@ -167,6 +171,11 @@ export class ReportingPublicPlugin share.url, params ); + + return () => { + docTitle.reset(); + umountAppCallback(); + }; }, }); diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index 051789b1896bbe..4b63acd5b01abc 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -18,7 +18,15 @@ import { AlertsClient } from './alert_data_client/alerts_client'; type SimpleAlertType< TParams extends AlertTypeParams = {}, TAlertInstanceContext extends AlertInstanceContext = {} -> = AlertType; +> = AlertType< + TParams, + TParams, + AlertTypeState, + AlertInstanceState, + TAlertInstanceContext, + string, + string +>; export type AlertTypeExecutor< TParams extends AlertTypeParams = {}, @@ -35,7 +43,15 @@ export type AlertTypeWithExecutor< TAlertInstanceContext extends AlertInstanceContext = {}, TServices extends Record = {} > = Omit< - AlertType, + AlertType< + TParams, + TParams, + AlertTypeState, + AlertInstanceState, + TAlertInstanceContext, + string, + string + >, 'executor' > & { executor: AlertTypeExecutor; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 9e266d774e86ec..4593d9a7ad6820 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -61,6 +61,8 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", + "idleTimeout": "PT1H", + "lifespan": "P30D", }, } `); @@ -110,6 +112,8 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", + "idleTimeout": "PT1H", + "lifespan": "P30D", }, } `); @@ -158,6 +162,8 @@ describe('config schema', () => { "secureCookies": false, "session": Object { "cleanupInterval": "PT1H", + "idleTimeout": "PT1H", + "lifespan": "P30D", }, } `); @@ -1615,11 +1621,11 @@ describe('createConfig()', () => { it('returns default values if neither global nor provider specific settings are set', async () => { expect(createMockConfig().session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` - Object { - "idleTimeout": null, - "lifespan": null, - } - `); + Object { + "idleTimeout": "PT1H", + "lifespan": "P30D", + } + `); }); it('correctly handles explicitly disabled global settings', async () => { @@ -1653,11 +1659,11 @@ describe('createConfig()', () => { name: 'basic1', }) ).toMatchInlineSnapshot(` - Object { - "idleTimeout": "PT0.123S", - "lifespan": null, - } - `); + Object { + "idleTimeout": "PT0.123S", + "lifespan": "P30D", + } + `); expect( createMockConfig({ session: { lifespan: 456 } }).session.getExpirationTimeouts({ @@ -1665,11 +1671,11 @@ describe('createConfig()', () => { name: 'basic1', }) ).toMatchInlineSnapshot(` - Object { - "idleTimeout": null, - "lifespan": "PT0.456S", - } - `); + Object { + "idleTimeout": "PT1H", + "lifespan": "PT0.456S", + } + `); expect( createMockConfig({ @@ -1692,7 +1698,7 @@ describe('createConfig()', () => { ).toMatchInlineSnapshot(` Object { "idleTimeout": "PT0.123S", - "lifespan": null, + "lifespan": "P30D", } `); @@ -1703,7 +1709,7 @@ describe('createConfig()', () => { }) ).toMatchInlineSnapshot(` Object { - "idleTimeout": null, + "idleTimeout": "PT1H", "lifespan": "PT0.456S", } `); @@ -1734,14 +1740,14 @@ describe('createConfig()', () => { .toMatchInlineSnapshot(` Object { "idleTimeout": "PT0.321S", - "lifespan": null, + "lifespan": "P30D", } `); expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' })) .toMatchInlineSnapshot(` Object { "idleTimeout": "PT5M32.211S", - "lifespan": null, + "lifespan": "P30D", } `); @@ -1758,14 +1764,14 @@ describe('createConfig()', () => { .toMatchInlineSnapshot(` Object { "idleTimeout": "PT0.321S", - "lifespan": null, + "lifespan": "P30D", } `); expect(configWithGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' })) .toMatchInlineSnapshot(` Object { "idleTimeout": "PT5M32.211S", - "lifespan": null, + "lifespan": "P30D", } `); }); @@ -1783,14 +1789,14 @@ describe('createConfig()', () => { expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": null, + "idleTimeout": "PT1H", "lifespan": "PT0.654S", } `); expect(configWithoutGlobal.session.getExpirationTimeouts({ type: 'saml', name: 'saml1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": null, + "idleTimeout": "PT1H", "lifespan": "PT11M5.544S", } `); @@ -1807,7 +1813,7 @@ describe('createConfig()', () => { expect(configWithGlobal.session.getExpirationTimeouts({ type: 'basic', name: 'basic1' })) .toMatchInlineSnapshot(` Object { - "idleTimeout": null, + "idleTimeout": "PT1H", "lifespan": "PT0.654S", } `); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index ce83a92e23ae7f..6ce161a8988109 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -209,8 +209,12 @@ export const ConfigSchema = schema.object({ schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) ), session: schema.object({ - idleTimeout: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])), - lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])), + idleTimeout: schema.oneOf([schema.duration(), schema.literal(null)], { + defaultValue: schema.duration().validate('1h'), + }), + lifespan: schema.oneOf([schema.duration(), schema.literal(null)], { + defaultValue: schema.duration().validate('30d'), + }), cleanupInterval: schema.duration({ defaultValue: '1h', validate(value) { @@ -385,7 +389,6 @@ export function createConfig( } function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) { - const defaultAnonymousSessionLifespan = schema.duration().validate('30d'); return { cleanupInterval: session.cleanupInterval, getExpirationTimeouts({ type, name }: AuthenticationProvider) { @@ -393,21 +396,9 @@ function getSessionConfig(session: RawConfigType['session'], providers: Provider // possible types of values: `Duration`, `null` and `undefined`. The `undefined` type means that // provider doesn't override session config and we should fall back to the global one instead. const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session; - - // We treat anonymous sessions differently since users can create them without realizing it. This may lead to a - // non controllable amount of sessions stored in the session index. To reduce the impact we set a 30 days lifespan - // for the anonymous sessions in case neither global nor provider specific lifespan is configured explicitly. - // We can remove this code once https://github.com/elastic/kibana/issues/68885 is resolved. - const providerLifespan = - type === 'anonymous' && - providerSessionConfig?.lifespan === undefined && - session.lifespan === undefined - ? defaultAnonymousSessionLifespan - : providerSessionConfig?.lifespan; - const [idleTimeout, lifespan] = [ [session.idleTimeout, providerSessionConfig?.idleTimeout], - [session.lifespan, providerLifespan], + [session.lifespan, providerSessionConfig?.lifespan], ].map(([globalTimeout, providerTimeout]) => { const timeout = providerTimeout === undefined ? globalTimeout ?? null : providerTimeout; return timeout && timeout.asMilliseconds() > 0 ? timeout : null; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index dfe6ba343ca3c0..e1c67dca667f75 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -358,7 +358,7 @@ describe('Session', () => { session = new Session({ logger: loggingSystemMock.createLogger(), config: createConfig( - ConfigSchema.validate({ session: { idleTimeout: 123 } }), + ConfigSchema.validate({ session: { idleTimeout: 123, lifespan: null } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), @@ -398,7 +398,7 @@ describe('Session', () => { session = new Session({ logger: loggingSystemMock.createLogger(), config: createConfig( - ConfigSchema.validate({ session: { lifespan } }), + ConfigSchema.validate({ session: { idleTimeout: null, lifespan } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), @@ -472,9 +472,11 @@ describe('Session', () => { session = new Session({ logger: loggingSystemMock.createLogger(), - config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { - isTLSEnabled: false, - }), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: null, lifespan: null } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), sessionCookie: mockSessionCookie, sessionIndex: mockSessionIndex, }); @@ -527,7 +529,7 @@ describe('Session', () => { session = new Session({ logger: loggingSystemMock.createLogger(), config: createConfig( - ConfigSchema.validate({ session: { idleTimeout: 123 } }), + ConfigSchema.validate({ session: { idleTimeout: 123, lifespan: null } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), @@ -718,7 +720,7 @@ describe('Session', () => { session = new Session({ logger: loggingSystemMock.createLogger(), config: createConfig( - ConfigSchema.validate({ session: { lifespan } }), + ConfigSchema.validate({ session: { idleTimeout: null, lifespan } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts index 11fb4ca27f5902..bf99f4926b1d67 100644 --- a/x-pack/plugins/security/server/session_management/session_index.test.ts +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -26,9 +26,11 @@ describe('Session index', () => { const sessionIndexOptions = { logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', - config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { - isTLSEnabled: false, - }), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: null, lifespan: null } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), elasticsearchClient: mockElasticsearchClient, }; @@ -239,7 +241,7 @@ describe('Session index', () => { logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', config: createConfig( - ConfigSchema.validate({ session: { lifespan: 456 } }), + ConfigSchema.validate({ session: { idleTimeout: null, lifespan: 456 } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), @@ -315,7 +317,7 @@ describe('Session index', () => { logger: loggingSystemMock.createLogger(), kibanaIndexName: '.kibana_some_tenant', config: createConfig( - ConfigSchema.validate({ session: { idleTimeout } }), + ConfigSchema.validate({ session: { idleTimeout, lifespan: null } }), loggingSystemMock.createLogger(), { isTLSEnabled: false } ), diff --git a/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts b/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts index 76ab48a8243db3..85d339970dc594 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/index_fields/index.ts @@ -13,8 +13,6 @@ import { } from '../../../../../../src/plugins/data/common'; import { DocValueFields, Maybe } from '../common'; -export type BeatFieldsFactoryQueryType = 'beatFields'; - interface FieldInfo { category: string; description?: string; diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts index 35f38db4f38d24..be726f0323d48c 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/fields_browser.spec.ts @@ -10,7 +10,6 @@ import { FIELDS_BROWSER_FIELDS_COUNT, FIELDS_BROWSER_HOST_CATEGORIES_COUNT, FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER, - FIELDS_BROWSER_HOST_GEO_COUNTRY_NAME_HEADER, FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER, FIELDS_BROWSER_MESSAGE_HEADER, FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, @@ -24,7 +23,6 @@ import { cleanKibana } from '../../tasks/common'; import { addsHostGeoCityNameToTimeline, addsHostGeoContinentNameToTimeline, - addsHostGeoCountryNameToTimelineDraggingIt, clearFieldsBrowser, closeFieldsBrowser, filterFieldsBrowser, @@ -156,18 +154,6 @@ describe('Fields Browser', () => { cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('exist'); }); - it('adds a field to the timeline when the user drags and drops a field', () => { - const filterInput = 'host.geo.c'; - - filterFieldsBrowser(filterInput); - - cy.get(FIELDS_BROWSER_HOST_GEO_COUNTRY_NAME_HEADER).should('not.exist'); - - addsHostGeoCountryNameToTimelineDraggingIt(); - - cy.get(FIELDS_BROWSER_HOST_GEO_COUNTRY_NAME_HEADER).should('exist'); - }); - it('resets all fields in the timeline when `Reset Fields` is clicked', () => { const filterInput = 'host.geo.c'; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 58cca7bcbd1213..2a3484318966f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -17,6 +17,8 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell import * as i18n from './translations'; import { useKibana } from '../../lib/kibana'; import { SourcererScopeName } from '../../store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; export interface OwnProps { end: string; @@ -74,20 +76,29 @@ const AlertsTableComponent: React.FC = ({ const alertsFilter = useMemo(() => [...defaultAlertsFilters, ...pageFilters], [pageFilters]); const { filterManager } = useKibana().services.data.query; + const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ id: timelineId, documentType: i18n.ALERTS_DOCUMENT_TYPE, filterManager, - defaultColumns: alertsDefaultModel.columns, + defaultColumns: alertsDefaultModel.columns.map((c) => + !tGridEnabled && c.initialWidth == null + ? { + ...c, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + } + : c + ), excludedRowRendererIds: alertsDefaultModel.excludedRowRendererIds, footerText: i18n.TOTAL_COUNT_OF_ALERTS, title: i18n.ALERTS_TABLE_TITLE, // TODO: avoid passing this through the store }) ); - }, [dispatch, filterManager, timelineId]); + }, [dispatch, filterManager, tGridEnabled, timelineId]); return ( { + const categoryField = find({ category: 'event', field: 'event.category' }, data) as + | TimelineEventsDetailsItem + | undefined; + const eventCategory = Array.isArray(categoryField?.originalValue) + ? categoryField?.originalValue[0] + : categoryField?.originalValue; + + const tableFields = + eventCategory === 'network' + ? networkFields + : eventCategory === 'process' + ? processFields + : fields; + return data != null - ? fields.reduce((acc, item) => { + ? tableFields.reduce((acc, item) => { const field = data.find((d) => d.field === item.id); if (!field) { return acc; @@ -213,21 +237,27 @@ const AlertSummaryViewComponent: React.FC<{ return ( <> - + {maybeRule?.note && ( - - {i18n.INVESTIGATION_GUIDE} - - - {maybeRule.note} - - - + <> + + +
{i18n.INVESTIGATION_GUIDE}
+
+ + + + + {maybeRule.note} + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx index 274e292f5d1c20..16b4e7ff7c44d1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx @@ -38,7 +38,7 @@ const RightMargin = styled.span` const EnrichmentTitle: React.FC = ({ title, type }) => ( <> - +
{title}
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index 539993b59c70dc..f323a8c8b4a084 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -82,7 +82,7 @@ describe('EventDetails', () => { }); describe('alerts tabs', () => { - ['Summary', 'Threat Intel', 'Table', 'JSON View'].forEach((tab) => { + ['Overview', 'Threat Intel', 'Table', 'JSON View'].forEach((tab) => { test(`it renders the ${tab} tab`, () => { const expectedCopy = tab === 'Threat Intel' ? `${tab} (1)` : tab; expect( @@ -94,14 +94,14 @@ describe('EventDetails', () => { }); }); - test('the Summary tab is selected by default', () => { + test('the Overview tab is selected by default', () => { expect( alertsWrapper .find('[data-test-subj="eventDetails"]') .find('.euiTab-isSelected') .first() .text() - ).toEqual('Summary'); + ).toEqual('Overview'); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 668c6ffb723aa0..39a8af135a9909 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -128,7 +128,7 @@ const EventDetailsComponent: React.FC = ({ isAlert ? { id: EventsViewType.summaryView, - name: i18n.SUMMARY, + name: i18n.OVERVIEW, content: ( <> = ({ eventId: id, browserFields, timelineId, + title: i18n.DUCOMENT_SUMMARY, }} /> {enrichmentCount > 0 && ( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 6002f66da43092..2b300789c4d148 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -197,7 +197,7 @@ export const onEventDetailsTabKeyPressed = ({ }; const getTitle = (title: string) => ( - +
{title}
); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx index 961860ed6d8b99..ddfa632d0199a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/summary_view.tsx @@ -5,12 +5,16 @@ * 2.0. */ -import { EuiInMemoryTable, EuiBasicTableColumn, EuiTitle, EuiHorizontalRule } from '@elastic/eui'; +import { EuiInMemoryTable, EuiBasicTableColumn, EuiTitle } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; import { SummaryRow } from './helpers'; +export const Indent = styled.div` + padding: 0 4px; +`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` .euiTableHeaderCell, @@ -22,24 +26,6 @@ export const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` } `; -const StyledEuiTitle = styled(EuiTitle)` - color: ${({ theme }) => theme.eui.euiColorDarkShade}; - text-transform: lowercase; - padding-top: ${({ theme }) => theme.eui.paddingSizes.s}; - h2 { - min-width: 120px; - } - hr { - max-width: 75%; - } -`; - -const FlexDiv = styled.div` - display: flex; - align-items: center; - justify-content: flex-start; -`; - export const SummaryViewComponent: React.FC<{ title?: string; summaryColumns: Array>; @@ -49,19 +35,18 @@ export const SummaryViewComponent: React.FC<{ return ( <> {title && ( - - -

{title}

- -
-
+ +
{title}
+
)} - + + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index c632f5d6332e0e..56d5009c34d720 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -7,21 +7,28 @@ import { i18n } from '@kbn/i18n'; -export const SUMMARY = i18n.translate('xpack.securitySolution.alertDetails.summary', { - defaultMessage: 'Summary', -}); - export const THREAT_INTEL = i18n.translate('xpack.securitySolution.alertDetails.threatIntel', { defaultMessage: 'Threat Intel', }); export const INVESTIGATION_GUIDE = i18n.translate( - 'xpack.securitySolution.alertDetails.summary.investigationGuide', + 'xpack.securitySolution.alertDetails.overview.investigationGuide', { defaultMessage: 'Investigation guide', } ); +export const OVERVIEW = i18n.translate('xpack.securitySolution.alertDetails.overview', { + defaultMessage: 'Overview', +}); + +export const DUCOMENT_SUMMARY = i18n.translate( + 'xpack.securitySolution.alertDetails.overview.documentSummary', + { + defaultMessage: 'Document Summary', + } +); + export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', { defaultMessage: 'Table', }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx index 5051b39fe60933..a511af16bbf715 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/default_headers.tsx @@ -7,10 +7,7 @@ import { ColumnHeaderOptions } from '../../../../common'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; -import { - DEFAULT_COLUMN_MIN_WIDTH, - DEFAULT_DATE_COLUMN_MIN_WIDTH, -} from '../../../timelines/components/timeline/body/constants'; +import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; export const defaultHeaders: ColumnHeaderOptions[] = [ { @@ -21,41 +18,33 @@ export const defaultHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, id: 'message', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.dataset', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'event.action', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, }, ]; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index ccba97f6a7942a..e324a54745c25d 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -209,7 +209,7 @@ describe('EventsViewer', () => { ); - expect(wrapper.find(`[data-test-subj="show-field-browser"]`).first().exists()).toBe(true); + expect(wrapper.find(`[data-test-subj="field-browser"]`).first().exists()).toBe(true); }); test('it renders the footer containing the pagination', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index bfc14a0f0c6803..c4da4e8d4506a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -27,6 +27,16 @@ import { CellValueElementProps } from '../../../timelines/components/timeline/ce import { useKibana } from '../../lib/kibana'; import { defaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; import { EventsViewer } from './events_viewer'; +import * as i18n from './translations'; + +const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; +const leadingControlColumns: ControlColumnProps[] = [ + { + ...defaultControlColumn, + // eslint-disable-next-line react/display-name + headerCellRender: () => <>{i18n.ACTIONS}, + }, +]; const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)}; @@ -115,8 +125,7 @@ const StatefulEventsViewerComponent: React.FC = ({ }, []); const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]); - const leadingControlColumns: ControlColumnProps[] = [defaultControlColumn]; - const trailingControlColumns: ControlColumnProps[] = []; + const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS; return ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/translations.ts b/x-pack/plugins/security_solution/public/common/components/events_viewer/translations.ts index 133ba1d98e0921..7c79bce1d73433 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/translations.ts @@ -27,3 +27,7 @@ export const UNIT = (totalCount: number) => values: { totalCount }, defaultMessage: `{totalCount, plural, =1 {event} other {events}}`, }); + +export const ACTIONS = i18n.translate('xpack.securitySolution.eventsViewer.actionsColumnLabel', { + defaultMessage: 'Actions', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 03ee38473e58d4..4ad26533cb58c7 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -181,11 +181,6 @@ export const getBreadcrumbsForRoute = ( } if (isAdminRoutes(spyState) && object.navTabs) { - const tempNav: SearchNavTab = { urlKey: 'administration', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } return [siemRootBreadcrumb, ...getAdminBreadcrumbs(spyState)]; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 08e88567b0fd01..69160d90a011eb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -26,6 +26,7 @@ import { import { ISearchStart } from '../../../../../../../src/plugins/data/public'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { getTimelineTemplate } from '../../../timelines/containers/api'; +import { defaultHeaders } from '../../../timelines/components/timeline/body/column_headers/default_headers'; jest.mock('../../../timelines/containers/api', () => ({ getTimelineTemplate: jest.fn(), @@ -139,6 +140,7 @@ describe('alert actions', () => { initialWidth: 180, }, ], + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { end: '2018-11-05T19:03:25.937Z', diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 0e32df851592d3..b67fd9aeb81b9b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -52,6 +52,7 @@ import { buildTimeRangeFilter } from './helpers'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { columns, RenderCellValue } from '../../configurations/security_solution_detections'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; interface OwnProps { defaultFilters?: Filter[]; @@ -343,10 +344,19 @@ export const AlertsTableComponent: React.FC = ({ ? alertsDefaultModelRuleRegistry : alertsDefaultModel; + const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ - defaultColumns: columns, + defaultColumns: columns.map((c) => + !tGridEnabled && c.initialWidth == null + ? { + ...c, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + } + : c + ), documentType: i18n.ALERTS_DOCUMENT_TYPE, excludedRowRendererIds: defaultTimelineModel.excludedRowRendererIds as RowRendererId[], filterManager, @@ -359,7 +369,7 @@ export const AlertsTableComponent: React.FC = ({ showCheckboxes: true, }) ); - }, [dispatch, defaultTimelineModel, filterManager, timelineId]); + }, [dispatch, defaultTimelineModel, filterManager, tGridEnabled, timelineId]); const headerFilterGroup = useMemo( () => , diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index c43c4547a17ec6..c63b4b73ae3152 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -18,6 +18,12 @@ export const ALERTS_DOCUMENT_TYPE = i18n.translate( } ); +export const ALERTS_UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.detectionEngine.alerts.alertsUnit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {alert} other {alerts}}`, + }); + export const OPEN_ALERTS = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.openAlertsTitle', { @@ -245,9 +251,23 @@ export const STATUS = i18n.translate( } ); +export const SIGNAL_STATUS = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.overviewTable.signalStatusTitle', + { + defaultMessage: 'Status', + } +); + export const TRIGGERED = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.triggeredTitle', { defaultMessage: 'Triggered', } ); + +export const TIMESTAMP = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.overviewTable.timestampTitle', + { + defaultMessage: 'Timestamp', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts index 70d2237a535ebb..5e1bf4d90fb46a 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/columns.ts @@ -47,6 +47,5 @@ export const columns: Array< columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_REASON, id: 'signal.reason', - initialWidth: 644, }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx index 7db75d3a73d907..d99fecb6bdadff 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.test.tsx @@ -41,6 +41,7 @@ describe('RenderCellValue', () => { eventId, header, isDetails: false, + isDraggable: true, isExpandable: false, isExpanded: false, linkValues, diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx index bc8c4bd6bfe69a..4eb885d4c9aea1 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/observablity_alerts/render_cell_value.tsx @@ -33,6 +33,7 @@ export const RenderCellValue: React.FC< eventId, header, isDetails, + isDraggable, isExpandable, isExpanded, linkValues, @@ -71,6 +72,7 @@ export const RenderCellValue: React.FC< eventId={eventId} header={header} isDetails={isDetails} + isDraggable={isDraggable} isExpandable={isExpandable} isExpanded={isExpanded} linkValues={linkValues} diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts index 3365ce5432940f..bf0801f276bdf1 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/columns.ts @@ -41,6 +41,5 @@ export const columns: Array< columnHeaderType: defaultColumnHeaderType, id: 'signal.reason', displayAsText: i18n.ALERTS_HEADERS_REASON, - initialWidth: 644, }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx index a8f295df2540d8..ccd71404a22161 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.test.tsx @@ -41,6 +41,7 @@ describe('RenderCellValue', () => { eventId, header, isDetails: false, + isDraggable: false, isExpandable: false, isExpanded: false, linkValues, diff --git a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx index 097cb54a7b0ef3..879712c85327ec 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/examples/security_solution_rac/render_cell_value.tsx @@ -67,6 +67,7 @@ export const RenderCellValue: React.FC< eventId={eventId} header={header} isDetails={isDetails} + isDraggable={false} isExpandable={isExpandable} isExpanded={isExpanded} linkValues={linkValues} diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 7f46c839ffe629..d6d3d829d3be56 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -62,7 +62,6 @@ export const columns: Array< { columnHeaderType: defaultColumnHeaderType, id: 'event.module', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'rule.reference', }, { @@ -70,32 +69,26 @@ export const columns: Array< category: 'event', columnHeaderType: defaultColumnHeaderType, id: 'event.action', - initialWidth: 140, type: 'string', }, { columnHeaderType: defaultColumnHeaderType, id: 'event.category', - initialWidth: 150, }, { columnHeaderType: defaultColumnHeaderType, id: 'host.name', - initialWidth: 120, }, { columnHeaderType: defaultColumnHeaderType, id: 'user.name', - initialWidth: 120, }, { columnHeaderType: defaultColumnHeaderType, id: 'source.ip', - initialWidth: 120, }, { columnHeaderType: defaultColumnHeaderType, id: 'destination.ip', - initialWidth: 140, }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx index 965ee913a1daa0..a7def2a23ef1d8 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.test.tsx @@ -41,6 +41,7 @@ describe('RenderCellValue', () => { eventId, header, isDetails: false, + isDraggable: false, isExpandable: false, isExpanded: false, linkValues, diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index e9bfdefa433c27..72914507bb6a66 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -22,6 +22,7 @@ export const RenderCellValue: React.FC< columnId, data, eventId, + isDraggable, header, isDetails, isExpandable, @@ -35,6 +36,7 @@ export const RenderCellValue: React.FC< columnId={columnId} data={data} eventId={eventId} + isDraggable={isDraggable} header={header} isDetails={isDetails} isExpandable={isExpandable} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 86bd8b5f47b0bd..3acf307cb9f418 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -24,6 +24,8 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery'; @@ -66,14 +68,23 @@ const EventsQueryTabBodyComponent: React.FC = ({ const dispatch = useDispatch(); const { globalFullScreen } = useGlobalFullScreen(); + const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); + useEffect(() => { dispatch( timelineActions.initializeTGridSettings({ id: TimelineId.hostsPageEvents, - defaultColumns: eventsDefaultModel.columns, + defaultColumns: eventsDefaultModel.columns.map((c) => + !tGridEnabled && c.initialWidth == null + ? { + ...c, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + } + : c + ), }) ); - }, [dispatch]); + }, [dispatch, tGridEnabled]); useEffect(() => { return () => { diff --git a/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx index dd7ad20d2384a3..fc48a022946d5b 100644 --- a/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/port/index.test.tsx @@ -31,7 +31,13 @@ describe('Port', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); @@ -39,7 +45,13 @@ describe('Port', () => { test('it renders the port', () => { const wrapper = mount( - + ); @@ -51,7 +63,13 @@ describe('Port', () => { test('it hyperlinks links destination.port to an external service that describes the purpose of the port', () => { const wrapper = mount( - + ); @@ -65,7 +83,13 @@ describe('Port', () => { test('it renders only one external link icon', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/network/components/port/index.tsx b/x-pack/plugins/security_solution/public/network/components/port/index.tsx index 8ee1616d4c77bc..df288c1abfb06c 100644 --- a/x-pack/plugins/security_solution/public/network/components/port/index.tsx +++ b/x-pack/plugins/security_solution/public/network/components/port/index.tsx @@ -29,17 +29,22 @@ export const Port = React.memo<{ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; value: string | undefined | null; -}>(({ contextId, eventId, fieldName, value }) => ( - +}>(({ contextId, eventId, fieldName, isDraggable, value }) => + isDraggable ? ( + + + + ) : ( - -)); + ) +); Port.displayName = 'Port'; diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/ip_with_port.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/ip_with_port.tsx index 9a0a79a8902b64..17b55c4229fcc7 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/ip_with_port.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/ip_with_port.tsx @@ -39,6 +39,7 @@ const PortWithSeparator = React.memo<{ data-test-subj="port" eventId={eventId} fieldName={portFieldName} + isDraggable={true} value={port} />
diff --git a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.tsx b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.tsx index 288364d1eb0cb3..db9773789bf549 100644 --- a/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.tsx +++ b/x-pack/plugins/security_solution/public/network/components/source_destination/source_destination_ip.tsx @@ -202,6 +202,7 @@ export const SourceDestinationIp = React.memo( data-test-subj="port" eventId={eventId} fieldName={`${type}.port`} + isDraggable={true} value={port} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx index ea8317346cd998..e868b3e4c21dd7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx @@ -26,6 +26,7 @@ describe('Duration', () => { contextId="test" eventId="abc" fieldName="event.duration" + isDraggable={true} value={`${ONE_MILLISECOND_AS_NANOSECONDS}`} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx index 9d2a6e1f70a5da..421ba5941eaefc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.tsx @@ -20,18 +20,23 @@ export const Duration = React.memo<{ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; value?: string | null; -}>(({ contextId, eventId, fieldName, value }) => ( - +}>(({ contextId, eventId, fieldName, isDraggable, value }) => + isDraggable ? ( + + + + ) : ( - -)); + ) +); Duration.displayName = 'Duration'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx index 89a91ee6da305d..73fb7c19a6f46d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.tsx @@ -5,71 +5,21 @@ * 2.0. */ -import { - EuiCheckbox, - EuiIcon, - EuiToolTip, - EuiFlexGroup, - EuiFlexItem, - EuiScreenReaderOnly, -} from '@elastic/eui'; -import { isEmpty, uniqBy } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import React, { useCallback, useRef, useState } from 'react'; import { Draggable } from 'react-beautiful-dnd'; -import styled from 'styled-components'; -import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '@kbn/securitysolution-t-grid'; -import { BrowserField, BrowserFields } from '../../../common/containers/source'; -import { DragEffects } from '../../../common/components/drag_and_drop/draggable_wrapper'; -import { DroppableWrapper } from '../../../common/components/drag_and_drop/droppable_wrapper'; import { - DRAG_TYPE_FIELD, + DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableFieldId, - getDroppableId, -} from '../../../common/components/drag_and_drop/helpers'; -import { DraggableFieldBadge } from '../../../common/components/draggables/field_badge'; -import { getEmptyValue } from '../../../common/components/empty_value'; -import { - getColumnsWithTimestamp, - getExampleText, - getIconFromType, -} from '../../../common/components/event_details/helpers'; -import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; -import { OnUpdateColumns } from '../timeline/events'; -import { TruncatableText } from '../../../common/components/truncatable_text'; +} from '@kbn/securitysolution-t-grid'; +import type { BrowserFields } from '../../../common/containers/source'; +import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers'; +import type { OnUpdateColumns } from '../timeline/events'; import { FieldName } from './field_name'; -import * as i18n from './translations'; -import { getAlertColumnHeader } from './helpers'; -import { ColumnHeaderOptions } from '../../../../common'; +import type { ColumnHeaderOptions } from '../../../../common'; import { useKibana } from '../../../common/lib/kibana'; -const TypeIcon = styled(EuiIcon)` - margin: 0 4px; - position: relative; - top: -1px; -`; - -TypeIcon.displayName = 'TypeIcon'; - -export const Description = styled.span` - user-select: text; - width: 400px; -`; - -Description.displayName = 'Description'; - -/** - * An item rendered in the table - */ -export interface FieldItem { - ariaRowindex?: number; - checkbox: React.ReactNode; - description: React.ReactNode; - field: React.ReactNode; - fieldId: string; -} - const DraggableFieldsBrowserFieldComponent = ({ browserFields, categoryId, @@ -191,142 +141,3 @@ const DraggableFieldsBrowserFieldComponent = ({ export const DraggableFieldsBrowserField = React.memo(DraggableFieldsBrowserFieldComponent); DraggableFieldsBrowserField.displayName = 'DraggableFieldsBrowserFieldComponent'; - -/** - * Returns the draggable fields, values, and descriptions shown when a user expands an event - */ -export const getFieldItems = ({ - browserFields, - category, - categoryId, - columnHeaders, - highlight = '', - onUpdateColumns, - timelineId, - toggleColumn, -}: { - browserFields: BrowserFields; - category: Partial; - categoryId: string; - columnHeaders: ColumnHeaderOptions[]; - highlight?: string; - timelineId: string; - toggleColumn: (column: ColumnHeaderOptions) => void; - onUpdateColumns: OnUpdateColumns; -}): FieldItem[] => - uniqBy('name', [ - ...Object.values(category != null && category.fields != null ? category.fields : {}), - ]).map((field) => ({ - checkbox: ( - - c.id === field.name) !== -1} - data-test-subj={`field-${field.name}-checkbox`} - data-colindex={1} - id={field.name ?? ''} - onChange={() => - toggleColumn({ - columnHeaderType: defaultColumnHeaderType, - id: field.name ?? '', - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - ...getAlertColumnHeader(timelineId, field.name ?? ''), - }) - } - /> - - ), - field: ( - - - - - - - - - ( -
- - - -
- )} - > - -
-
-
- ), - description: ( -
- - <> - -

{i18n.DESCRIPTION_FOR_FIELD(field.name ?? '')}

-
- - - {`${field.description ?? getEmptyValue()} ${getExampleText(field.example)}`} - - - -
-
- ), - fieldId: field.name ?? '', - })); - -/** - * Returns a table column template provided to the `EuiInMemoryTable`'s - * `columns` prop - */ -export const getFieldColumns = () => [ - { - field: 'checkbox', - name: '', - render: (checkbox: React.ReactNode, _: FieldItem) => checkbox, - sortable: false, - width: '25px', - }, - { - field: 'field', - name: i18n.FIELD, - render: (field: React.ReactNode, _: FieldItem) => field, - sortable: false, - width: '225px', - }, - { - field: 'description', - name: i18n.DESCRIPTION, - render: (description: React.ReactNode, _: FieldItem) => description, - sortable: false, - truncateText: true, - width: '400px', - }, -]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx index 8cdb263fe42bbc..235b3f0b9300ab 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/formatted_ip/index.tsx @@ -85,9 +85,10 @@ const NonDecoratedIpComponent: React.FC<{ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; truncate?: boolean; value: string | object | null | undefined; -}> = ({ contextId, eventId, fieldName, truncate, value }) => { +}> = ({ contextId, eventId, fieldName, isDraggable, truncate, value }) => { const key = useMemo( () => `non-decorated-ip-draggable-wrapper-${getUniqueId({ @@ -104,20 +105,30 @@ const NonDecoratedIpComponent: React.FC<{ [contextId, eventId, fieldName, value] ); + const content = useMemo( + () => + typeof value !== 'object' + ? getOrEmptyTagFromValue(value) + : getOrEmptyTagFromValue(tryStringify(value)), + [value] + ); + const render = useCallback( (dataProvider, _, snapshot) => snapshot.isDragging ? ( - ) : typeof value !== 'object' ? ( - getOrEmptyTagFromValue(value) ) : ( - getOrEmptyTagFromValue(tryStringify(value)) + content ), - [value] + [content] ); + if (!isDraggable) { + return content; + } + return ( = ({ contextId, eventId, fieldName, + isDraggable, truncate, }) => { const key = `address-links-draggable-wrapper-${getUniqueId({ @@ -189,6 +201,23 @@ const AddressLinksItemComponent: React.FC = ({ [eventContext, isInTimelineContext, address, fieldName, dispatch] ); + const content = useMemo( + () => ( + + + {address} + + + ), + [address, fieldName, formatUrl, isInTimelineContext, openNetworkDetailsSidePanel] + ); + const render = useCallback( (_props, _provided, snapshot) => snapshot.isDragging ? ( @@ -196,28 +225,15 @@ const AddressLinksItemComponent: React.FC = ({ ) : ( - - - {address} - - + content ), - [ - dataProviderProp, - fieldName, - address, - formatUrl, - isInTimelineContext, - openNetworkDetailsSidePanel, - ] + [dataProviderProp, content] ); + if (!isDraggable) { + return content; + } + return ( = ({ contextId, eventId, fieldName, + isDraggable, truncate, }) => { const uniqAddresses = useMemo(() => uniq(addresses), [addresses]); @@ -256,10 +274,11 @@ const AddressLinksComponent: React.FC = ({ contextId={contextId} eventId={eventId} fieldName={fieldName} + isDraggable={isDraggable} truncate={truncate} /> )), - [contextId, eventId, fieldName, truncate, uniqAddresses] + [contextId, eventId, fieldName, isDraggable, truncate, uniqAddresses] ); return <>{content}; @@ -271,6 +290,7 @@ const AddressLinks = React.memo( prevProps.contextId === nextProps.contextId && prevProps.eventId === nextProps.eventId && prevProps.fieldName === nextProps.fieldName && + prevProps.isDraggable === nextProps.isDraggable && prevProps.truncate === nextProps.truncate && deepEqual(prevProps.addresses, nextProps.addresses) ); @@ -279,9 +299,10 @@ const FormattedIpComponent: React.FC<{ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; truncate?: boolean; value: string | object | null | undefined; -}> = ({ contextId, eventId, fieldName, truncate, value }) => { +}> = ({ contextId, eventId, fieldName, isDraggable, truncate, value }) => { if (isString(value) && !isEmpty(value)) { try { const addresses = JSON.parse(value); @@ -292,6 +313,7 @@ const FormattedIpComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + isDraggable={isDraggable} truncate={truncate} /> ); @@ -306,6 +328,7 @@ const FormattedIpComponent: React.FC<{ addresses={[value]} contextId={contextId} eventId={eventId} + isDraggable={isDraggable} fieldName={fieldName} truncate={truncate} /> @@ -316,6 +339,7 @@ const FormattedIpComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + isDraggable={isDraggable} truncate={truncate} value={value} /> @@ -329,6 +353,7 @@ export const FormattedIp = React.memo( prevProps.contextId === nextProps.contextId && prevProps.eventId === nextProps.eventId && prevProps.fieldName === nextProps.fieldName && + prevProps.isDraggable === nextProps.isDraggable && prevProps.truncate === nextProps.truncate && deepEqual(prevProps.value, nextProps.value) ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts index c0fea1f210a8a5..ae15768d26e70d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.test.ts @@ -51,6 +51,7 @@ import { mockTemplate as mockSelectedTemplate, } from './__mocks__'; import { getTimeline } from '../../containers/api'; +import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; jest.mock('../../../common/store/inputs/actions'); jest.mock('../../../common/components/url_state/normalize_time_range.ts'); @@ -236,6 +237,49 @@ describe('helpers', () => { }); describe('#defaultTimelineToTimelineModel', () => { + const columns = [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + type: 'number', + initialWidth: 190, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + ]; test('if title is null, we should get the default title', () => { const timeline = { savedObjectId: 'savedObject-1', @@ -247,49 +291,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, description: '', @@ -358,49 +361,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, description: '', @@ -469,49 +431,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, description: '', @@ -578,49 +499,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, description: '', @@ -677,9 +557,12 @@ describe('helpers', () => { }); test('should merge columns when event.action is deleted without two extra column names of user.name', () => { + const columnsWithoutEventAction = timelineDefaults.columns.filter( + (column) => column.id !== 'event.action' + ); const timeline = { savedObjectId: 'savedObject-1', - columns: timelineDefaults.columns.filter((column) => column.id !== 'event.action'), + columns: columnsWithoutEventAction, version: '1', }; @@ -688,85 +571,8 @@ describe('helpers', () => { activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, savedObjectId: 'savedObject-1', - columns: [ - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: '@timestamp', - placeholder: undefined, - type: 'number', - initialWidth: 190, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'message', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'event.category', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'host.name', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'source.ip', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'destination.ip', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - { - aggregatable: undefined, - category: undefined, - columnHeaderType: 'not-filtered', - description: undefined, - example: undefined, - id: 'user.name', - placeholder: undefined, - type: undefined, - initialWidth: 180, - }, - ], + columns: columnsWithoutEventAction, + defaultColumns: defaultHeaders, version: '1', dataProviders: [], dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, @@ -822,9 +628,12 @@ describe('helpers', () => { }); test('should merge filters object back with json object', () => { + const columnsWithoutEventAction = timelineDefaults.columns.filter( + (column) => column.id !== 'event.action' + ); const timeline = { savedObjectId: 'savedObject-1', - columns: timelineDefaults.columns.filter((column) => column.id !== 'event.action'), + columns: columnsWithoutEventAction, filters: [ { meta: { @@ -865,44 +674,8 @@ describe('helpers', () => { activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, savedObjectId: 'savedObject-1', - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns: columnsWithoutEventAction, + defaultColumns: defaultHeaders, version: '1', dateRange: { start: '2020-07-07T08:20:18.966Z', end: '2020-07-08T08:20:18.966Z' }, dataProviders: [], @@ -1013,49 +786,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { end: '2020-10-28T11:37:31.655Z', start: '2020-10-27T11:37:31.655Z' }, description: '', @@ -1124,49 +856,8 @@ describe('helpers', () => { expect(newTimeline).toEqual({ activeTab: TimelineTabs.query, prevActiveTab: TimelineTabs.query, - columns: [ - { - columnHeaderType: 'not-filtered', - id: '@timestamp', - type: 'number', - initialWidth: 190, - }, - { - columnHeaderType: 'not-filtered', - id: 'message', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.category', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'event.action', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'host.name', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'source.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'destination.ip', - initialWidth: 180, - }, - { - columnHeaderType: 'not-filtered', - id: 'user.name', - initialWidth: 180, - }, - ], + columns, + defaultColumns: defaultHeaders, dataProviders: [], dateRange: { end: '2020-07-08T08:20:18.966Z', start: '2020-07-07T08:20:18.966Z' }, description: '', diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index 03ac0b3d14342a..0dda12d6127770 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -257,6 +257,7 @@ export const defaultTimelineToTimelineModel = ( const timelineEntries = { ...timeline, columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + defaultColumns: defaultHeaders, dateRange: timeline.status === TimelineStatus.immutable && timeline.timelineType === TimelineType.template diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index edf1a50787a578..8118555cd64d85 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -56,6 +56,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should handleOnEventClosed={[Function]} isAlert={false} loading={true} + ruleName="" >
- -
+ />
- -
+ />
@@ -522,6 +516,7 @@ Array [
- -
+ />
diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 47f6fe6606d3db..31cc61d4996a83 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -5,27 +5,22 @@ * 2.0. */ -import { find } from 'lodash/fp'; +import { isEmpty } from 'lodash/fp'; import { EuiButtonIcon, EuiTextColor, EuiLoadingContent, EuiTitle, - EuiSpacer, - EuiDescriptionList, - EuiDescriptionListTitle, - EuiDescriptionListDescription, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails } from '../../../../common/components/event_details/event_details'; import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; -import { LineClamp } from '../../../../common/components/line_clamp'; import * as i18n from './translations'; export type HandleOnEventClosed = () => void; @@ -43,6 +38,7 @@ interface Props { interface ExpandableEventTitleProps { isAlert: boolean; loading: boolean; + ruleName?: string; handleOnEventClosed?: HandleOnEventClosed; } @@ -63,12 +59,14 @@ const StyledEuiFlexItem = styled(EuiFlexItem)` `; export const ExpandableEventTitle = React.memo( - ({ isAlert, loading, handleOnEventClosed }) => ( + ({ isAlert, loading, handleOnEventClosed, ruleName }) => ( - - {!loading ?

{isAlert ? i18n.ALERT_DETAILS : i18n.EVENT_DETAILS}

: <>} -
+ {!loading && ( + +

{isAlert && !isEmpty(ruleName) ? ruleName : i18n.EVENT_DETAILS}

+
+ )}
{handleOnEventClosed && ( @@ -83,21 +81,6 @@ ExpandableEventTitle.displayName = 'ExpandableEventTitle'; export const ExpandableEvent = React.memo( ({ browserFields, event, timelineId, timelineTabType, isAlert, loading, detailsData }) => { - const message = useMemo(() => { - if (detailsData) { - const messageField = find({ category: 'base', field: 'message' }, detailsData) as - | TimelineEventsDetailsItem - | undefined; - - if (messageField?.originalValue) { - return Array.isArray(messageField?.originalValue) - ? messageField?.originalValue.join() - : messageField?.originalValue; - } - } - return null; - }, [detailsData]); - if (!event.eventId) { return {i18n.EVENT_DETAILS_PLACEHOLDER}; } @@ -108,17 +91,6 @@ export const ExpandableEvent = React.memo( return ( - {message && ( - - - {i18n.MESSAGE} - - {message} - - - - - )} { + const currentField = find({ category, field }, data)?.values; + return currentField && currentField.length > 0 ? currentField[0] : ''; +}; + interface EventDetailsPanelProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; @@ -106,31 +121,34 @@ const EventDetailsPanelComponent: React.FC = ({ return endpointAlertCheck({ data: detailsData || [] }); }, [detailsData]); - const agentId = useMemo(() => { - const findAgentId = find({ category: 'agent', field: 'agent.id' }, detailsData)?.values; - return findAgentId ? findAgentId[0] : ''; - }, [detailsData]); + const ruleName = useMemo( + () => getFieldValue({ category: 'signal', field: 'signal.rule.name' }, detailsData), + [detailsData] + ); - const hostOsFamily = useMemo(() => { - const findOsName = find({ category: 'host', field: 'host.os.name' }, detailsData)?.values; - return findOsName ? findOsName[0] : ''; - }, [detailsData]); + const agentId = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData), + [detailsData] + ); - const agentVersion = useMemo(() => { - const findAgentVersion = find({ category: 'agent', field: 'agent.version' }, detailsData) - ?.values; - return findAgentVersion ? findAgentVersion[0] : ''; - }, [detailsData]); + const hostOsFamily = useMemo( + () => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData), + [detailsData] + ); - const alertId = useMemo(() => { - const findAlertId = find({ category: '_id', field: '_id' }, detailsData)?.values; - return findAlertId ? findAlertId[0] : ''; - }, [detailsData]); + const agentVersion = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData), + [detailsData] + ); - const hostName = useMemo(() => { - const findHostName = find({ category: 'host', field: 'host.name' }, detailsData)?.values; - return findHostName ? findHostName[0] : ''; - }, [detailsData]); + const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, detailsData), [ + detailsData, + ]); + + const hostName = useMemo( + () => getFieldValue({ category: 'host', field: 'host.name' }, detailsData), + [detailsData] + ); const isolationSupported = isIsolationSupported({ osName: hostOsFamily, @@ -177,7 +195,7 @@ const EventDetailsPanelComponent: React.FC = ({ {isHostIsolationPanelOpen ? ( backToAlertDetailsLink ) : ( - + )} {isIsolateActionSuccessBannerVisible && ( @@ -225,6 +243,7 @@ const EventDetailsPanelComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx index 2daebdf37e77fb..fb483190577888 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/header_actions.tsx @@ -29,14 +29,13 @@ import { useTimelineFullScreen, } from '../../../../../common/containers/use_full_screen'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; -import { StatefulFieldsBrowser } from '../../../fields_browser'; import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser'; -import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../../../fields_browser/helpers'; import { EventsTh, EventsThContent } from '../../styles'; import { EventsSelect } from '../column_headers/events_select'; import * as i18n from '../column_headers/translations'; import { timelineActions } from '../../../../store/timeline'; import { isFullScreen } from '../column_headers'; +import { useKibana } from '../../../../../common/lib/kibana'; const SortingColumnsContainer = styled.div` button { @@ -52,6 +51,11 @@ const SortingColumnsContainer = styled.div` } `; +const ActionsContainer = styled.div` + align-items: center; + display: flex; +`; + const HeaderActionsComponent: React.FC = ({ width, browserFields, @@ -65,6 +69,7 @@ const HeaderActionsComponent: React.FC = ({ tabType, timelineId, }) => { + const { timelines: timelinesUi } = useKibana().services; const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const dispatch = useDispatch(); @@ -111,35 +116,36 @@ const HeaderActionsComponent: React.FC = ({ const sortedColumns = useMemo( () => ({ onSort: onSortColumns, - columns: sort.map<{ id: string; direction: 'asc' | 'desc' }>( - ({ columnId, sortDirection }) => ({ + columns: + sort?.map<{ id: string; direction: 'asc' | 'desc' }>(({ columnId, sortDirection }) => ({ id: columnId, direction: sortDirection as 'asc' | 'desc', - }) - ), + })) ?? [], }), [onSortColumns, sort] ); const displayValues = useMemo( - () => columnHeaders.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.displayAsText ?? ch.id }), {}), + () => + columnHeaders?.reduce((acc, ch) => ({ ...acc, [ch.id]: ch.displayAsText ?? ch.id }), {}) ?? + {}, [columnHeaders] ); const myColumns = useMemo( () => - columnHeaders.map(({ aggregatable, displayAsText, id, type }) => ({ + columnHeaders?.map(({ aggregatable, displayAsText, id, type }) => ({ id, isSortable: aggregatable, displayAsText, schema: type, - })), + })) ?? [], [columnHeaders] ); const ColumnSorting = useDataGridColumnSorting(myColumns, sortedColumns, {}, [], displayValues); return ( - <> + {showSelectAllCheckbox && ( @@ -154,14 +160,11 @@ const HeaderActionsComponent: React.FC = ({ )} - + {timelinesUi.getFieldBrowser({ + browserFields, + columnHeaders, + timelineId, + })} @@ -210,10 +213,9 @@ const HeaderActionsComponent: React.FC = ({ )} - + ); }; - HeaderActionsComponent.displayName = 'HeaderActionsComponent'; export const HeaderActions = React.memo(HeaderActionsComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 29e00d169b4e4b..e317b3cc140acc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -9,6 +9,8 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; +import styled from 'styled-components'; + import { eventHasNotes, getEventType, @@ -28,6 +30,11 @@ import { TimelineId, ActionProps, OnPinEvent } from '../../../../../../common/ty import { timelineActions, timelineSelectors } from '../../../../store/timeline'; import { timelineDefaults } from '../../../../store/timeline/defaults'; +const ActionsContainer = styled.div` + align-items: center; + display: flex; +`; + const ActionsComponent: React.FC = ({ ariaRowindex, width, @@ -93,7 +100,7 @@ const ActionsComponent: React.FC = ({ ); return ( - <> + {showCheckboxes && (
@@ -179,7 +186,7 @@ const ActionsComponent: React.FC = ({ onRuleChange={onRuleChange} /> - + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 2fcfed6489eb26..85e884703c5921 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -12,8 +12,7 @@ import { getActionsColumnWidth, getColumnWidthFromType, getColumnHeaders } from import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, - DEFAULT_ACTIONS_COLUMN_WIDTH, - EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, + MINIMUM_ACTIONS_COLUMN_WIDTH, SHOW_CHECK_BOXES_COLUMN_WIDTH, } from '../constants'; import '../../../../../common/mock/match_media'; @@ -31,22 +30,22 @@ describe('helpers', () => { describe('getActionsColumnWidth', () => { test('returns the default actions column width when isEventViewer is false', () => { - expect(getActionsColumnWidth(false)).toEqual(DEFAULT_ACTIONS_COLUMN_WIDTH); + expect(getActionsColumnWidth(false)).toEqual(MINIMUM_ACTIONS_COLUMN_WIDTH); }); - test('returns the default actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => { + test('returns the minimum actions column width + checkbox width when isEventViewer is false and showCheckboxes is true', () => { expect(getActionsColumnWidth(false, true)).toEqual( - DEFAULT_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + MINIMUM_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH ); }); - test('returns the events viewer actions column width when isEventViewer is true', () => { - expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH); + test('returns the minimum actions column width when isEventViewer is true', () => { + expect(getActionsColumnWidth(true)).toEqual(MINIMUM_ACTIONS_COLUMN_WIDTH); }); - test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { + test('returns the minimum actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { expect(getActionsColumnWidth(true, true)).toEqual( - EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + MINIMUM_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH ); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts index c49d088d6241d3..760c132cd18240 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.ts @@ -23,17 +23,19 @@ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], browserFields: BrowserFields ): ColumnHeaderOptions[] => { - return headers.map((header) => { - const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] + return headers + ? headers.map((header) => { + const splitHeader = header.id.split('.'); // source.geo.city_name -> [source, geo, city_name] - return { - ...header, - ...get( - [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], - browserFields - ), - }; - }); + return { + ...header, + ...get( + [splitHeader.length > 1 ? splitHeader[0] : 'base', 'fields', header.id], + browserFields + ), + }; + }) + : []; }; export const getColumnWidthFromType = (type: string): number => diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index 445211229574b5..37febc1c291f1b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -6,7 +6,7 @@ */ /** The minimum (fixed) width of the Actions column */ -export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px; +export const MINIMUM_ACTIONS_COLUMN_WIDTH = 148; // px; /** Additional column width to include when checkboxes are shown **/ export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx index 7931e0739aa68a..403756a763808b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/stateful_cell.tsx @@ -47,6 +47,7 @@ const StatefulCellComponent = ({ eventId, data, header, + isDraggable: true, isExpandable: true, isExpanded: false, isDetails: false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap index 4da4e12e0f7b3c..5c42306f563df8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/get_column_renderer.test.tsx.snap @@ -8,6 +8,7 @@ exports[`get_column_renderer renders correctly against snapshot 1`] = ` fieldFormat="" fieldName="event.severity" fieldType="" + isDraggable={true} key="plain-column-renderer-formatted-field-value-test-event.severity-1-message-3-0" value="3" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap index 13912e6ad3da92..b9859fc5453b7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/__snapshots__/plain_column_renderer.test.tsx.snap @@ -8,6 +8,7 @@ exports[`plain_column_renderer rendering renders correctly against snapshot 1`] fieldFormat="" fieldName="event.category" fieldType="keyword" + isDraggable={true} key="plain-column-renderer-formatted-field-value-test-event.category-1-event.category-Access-0" value="Access" /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx index dac10f46487841..417cf0ceee1846 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/agent_statuses.tsx @@ -18,11 +18,13 @@ export const AgentStatuses = React.memo( fieldName, contextId, eventId, + isDraggable, value, }: { fieldName: string; contextId: string; eventId: string; + isDraggable: boolean; value: string; }) => { const { @@ -36,14 +38,18 @@ export const AgentStatuses = React.memo( {agentStatus !== undefined ? ( - + {isDraggable ? ( + + + + ) : ( - + )} ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx index c7da6f758766e0..8930a813cde6f7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx @@ -22,7 +22,13 @@ describe('Bytes', () => { test('it renders the expected formatted bytes', () => { const wrapper = mount( - + ); expect(wrapper.find(PreferenceFormattedBytes).first().text()).toEqual('1.2MB'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx index 25b58dac918dd8..e2418334dfc804 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.tsx @@ -20,18 +20,23 @@ export const Bytes = React.memo<{ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; value?: string | null; -}>(({ contextId, eventId, fieldName, value }) => ( - +}>(({ contextId, eventId, fieldName, isDraggable, value }) => + isDraggable ? ( + + + + ) : ( - -)); + ) +); Bytes.displayName = 'Bytes'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts index 65bb67458ab2a8..fc13680b81be2b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts @@ -15,6 +15,7 @@ export interface ColumnRenderer { columnName, eventId, field, + isDraggable, timelineId, truncate, values, @@ -23,6 +24,7 @@ export interface ColumnRenderer { columnName: string; eventId: string; field: ColumnHeaderOptions; + isDraggable?: boolean; timelineId: string; truncate?: boolean; values: string[] | null | undefined; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx index 37873df7f4e7be..8e2335a2f149b9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.tsx @@ -31,43 +31,48 @@ export const emptyColumnRenderer: ColumnRenderer = { columnName, eventId, field, + isDraggable = true, timelineId, truncate, }: { columnName: string; eventId: string; field: ColumnHeaderOptions; + isDraggable?: boolean; timelineId: string; truncate?: boolean; - }) => ( - - snapshot.isDragging ? ( - - - - ) : ( - {getEmptyValue()} - ) - } - truncate={truncate} - /> - ), + }) => + isDraggable ? ( + + snapshot.isDragging ? ( + + + + ) : ( + {getEmptyValue()} + ) + } + truncate={truncate} + /> + ) : ( + {getEmptyValue()} + ), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx index 1d04849b198ad8..aa6c7beb9139e3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.tsx @@ -50,6 +50,7 @@ const FormattedFieldValueComponent: React.FC<{ fieldFormat?: string; fieldName: string; fieldType?: string; + isDraggable?: boolean; truncate?: boolean; value: string | number | undefined | null; linkValue?: string | null | undefined; @@ -60,6 +61,7 @@ const FormattedFieldValueComponent: React.FC<{ fieldName, fieldType, isObjectArray = false, + isDraggable = true, truncate, value, linkValue, @@ -72,6 +74,7 @@ const FormattedFieldValueComponent: React.FC<{ eventId={eventId} contextId={contextId} fieldName={fieldName} + isDraggable={isDraggable} value={!isNumber(value) ? value : String(value)} truncate={truncate} /> @@ -79,7 +82,7 @@ const FormattedFieldValueComponent: React.FC<{ } else if (fieldType === GEO_FIELD_TYPE) { return <>{value}; } else if (fieldType === DATE_FIELD_TYPE) { - return ( + return isDraggable ? ( + ) : ( + ); } else if (PORT_NAMES.some((portName) => fieldName === portName)) { return ( - + ); } else if (fieldName === EVENT_DURATION_FIELD_NAME) { return ( - + ); } else if (fieldName === HOST_NAME_FIELD_NAME) { - return ; + return ( + + ); } else if (fieldFormat === BYTES_FORMAT) { return ( - + ); } else if (fieldName === SIGNAL_RULE_NAME_FIELD_NAME) { return ( @@ -109,16 +140,31 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + isDraggable={isDraggable} linkValue={linkValue} truncate={truncate} value={value} /> ); } else if (fieldName === EVENT_MODULE_FIELD_NAME) { - return renderEventModule({ contextId, eventId, fieldName, linkValue, truncate, value }); + return renderEventModule({ + contextId, + eventId, + fieldName, + isDraggable, + linkValue, + truncate, + value, + }); } else if (fieldName === SIGNAL_STATUS_FIELD_NAME) { return ( - + ); } else if (fieldName === AGENT_STATUS_FIELD_NAME) { return ( @@ -126,6 +172,7 @@ const FormattedFieldValueComponent: React.FC<{ contextId={contextId} eventId={eventId} fieldName={fieldName} + isDraggable={isDraggable} value={typeof value === 'string' ? value : ''} /> ); @@ -137,8 +184,8 @@ const FormattedFieldValueComponent: React.FC<{ INDICATOR_REFERENCE, ].includes(fieldName) ) { - return renderUrl({ contextId, eventId, fieldName, linkValue, truncate, value }); - } else if (columnNamesNotDraggable.includes(fieldName)) { + return renderUrl({ contextId, eventId, fieldName, linkValue, isDraggable, truncate, value }); + } else if (columnNamesNotDraggable.includes(fieldName) || !isDraggable) { return truncate && !isEmpty(value) ? ( = ({ contextId, eventId, fieldName, + isDraggable, linkValue, truncate, value, @@ -63,13 +65,8 @@ export const RenderRuleName: React.FC = ({ [navigateToApp, ruleId, search] ); - return isString(value) && ruleName.length > 0 && ruleId != null ? ( - + if (isString(value) && ruleName.length > 0 && ruleId != null) { + const link = ( = ({ > {content} - - ) : value != null ? ( - - {value} - - ) : ( - getEmptyTagValue() - ); + ); + + return isDraggable ? ( + + {link} + + ) : ( + link + ); + } else if (value != null) { + return isDraggable ? ( + + {value} + + ) : ( + <>{value} + ); + } + + return getEmptyTagValue(); }; const canYouAddEndpointLogo = (moduleName: string, endpointUrl: string | null | undefined) => @@ -105,6 +119,7 @@ export const renderEventModule = ({ contextId, eventId, fieldName, + isDraggable, linkValue, truncate, value, @@ -112,6 +127,7 @@ export const renderEventModule = ({ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; linkValue: string | null | undefined; truncate?: boolean; value: string | number | null | undefined; @@ -130,14 +146,18 @@ export const renderEventModule = ({ } > - - {content} - + {isDraggable ? ( + + {content} + + ) : ( + <>{content} + )} {endpointRefUrl != null && canYouAddEndpointLogo(moduleName, endpointRefUrl) && ( @@ -166,6 +186,7 @@ export const renderUrl = ({ contextId, eventId, fieldName, + isDraggable, linkValue, truncate, value, @@ -173,28 +194,38 @@ export const renderUrl = ({ contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; linkValue: string | null | undefined; truncate?: boolean; value: string | number | null | undefined; }) => { const urlName = `${value}`; - const content = truncate ? {value} : value; - - return isString(value) && urlName.length > 0 ? ( - + const formattedValue = truncate ? {value} : value; + const content = ( + <> {!isUrlInvalid(urlName) && ( - {content} + {formattedValue} )} - {isUrlInvalid(urlName) && <>{content}} - + {isUrlInvalid(urlName) && <>{formattedValue}} + + ); + + return isString(value) && urlName.length > 0 ? ( + isDraggable ? ( + + {content} + + ) : ( + content + ) ) : ( getEmptyTagValue() ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx index e40ccec7bef342..abd4731ec4b668 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_name.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useContext } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { isString } from 'lodash/fp'; import { LinkAnchor } from '../../../../../common/components/links'; @@ -27,10 +27,17 @@ interface Props { contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; value: string | number | undefined | null; } -const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, value }) => { +const HostNameComponent: React.FC = ({ + fieldName, + contextId, + eventId, + isDraggable, + value, +}) => { const dispatch = useDispatch(); const eventContext = useContext(StatefulEventContext); const hostName = `${value}`; @@ -66,13 +73,8 @@ const HostNameComponent: React.FC = ({ fieldName, contextId, eventId, val [dispatch, eventContext, isInTimelineContext, hostName] ); - return isString(value) && hostName.length > 0 ? ( - + const content = useMemo( + () => ( = ({ fieldName, contextId, eventId, val > {hostName} - + ), + [formatUrl, hostName, isInTimelineContext, openHostDetailsSidePanel] + ); + + return isString(value) && hostName.length > 0 ? ( + isDraggable ? ( + + {content} + + ) : ( + content + ) ) : ( getEmptyTagValue() ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx index 77039ddc4a586c..8509e7be0d22bf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.tsx @@ -26,6 +26,7 @@ export const plainColumnRenderer: ColumnRenderer = { columnName, eventId, field, + isDraggable = true, timelineId, truncate, values, @@ -34,6 +35,7 @@ export const plainColumnRenderer: ColumnRenderer = { columnName: string; eventId: string; field: ColumnHeaderOptions; + isDraggable?: boolean; timelineId: string; truncate?: boolean; values: string[] | undefined | null; @@ -48,6 +50,7 @@ export const plainColumnRenderer: ColumnRenderer = { fieldFormat={field.format || ''} fieldName={columnName} fieldType={field.type || ''} + isDraggable={isDraggable} value={parseValue(value)} truncate={truncate} linkValue={head(linkValues)} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx index 08a16437ff5457..126bfae996ef7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/rule_status.tsx @@ -26,12 +26,19 @@ interface Props { contextId: string; eventId: string; fieldName: string; + isDraggable: boolean; value: string | number | undefined | null; } -const RuleStatusComponent: React.FC = ({ contextId, eventId, fieldName, value }) => { +const RuleStatusComponent: React.FC = ({ + contextId, + eventId, + fieldName, + isDraggable, + value, +}) => { const color = useMemo(() => getOr('default', `${value}`, mapping), [value]); - return ( + return isDraggable ? ( = ({ contextId, eventId, fieldName, v > {value} + ) : ( + {value} ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx index 06d8133a24f6e6..5282276f8bb51e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.test.tsx @@ -55,6 +55,7 @@ describe('DefaultCellRenderer', () => { eventId={eventId} header={header} isDetails={isDetails} + isDraggable={true} isExpandable={isExpandable} isExpanded={isExpanded} linkValues={linkValues} @@ -84,6 +85,7 @@ describe('DefaultCellRenderer', () => { eventId={eventId} header={header} isDetails={isDetails} + isDraggable={true} isExpandable={isExpandable} isExpanded={isExpanded} linkValues={linkValues} @@ -100,6 +102,7 @@ describe('DefaultCellRenderer', () => { columnName: header.id, eventId, field: header, + isDraggable: true, linkValues, timelineId, truncate: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index 8d8f821107e7bc..d2652ed063fc7c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -18,6 +18,7 @@ export const DefaultCellRenderer: React.FC = ({ data, eventId, header, + isDraggable, linkValues, setCellProps, timelineId, @@ -27,6 +28,7 @@ export const DefaultCellRenderer: React.FC = ({ columnName: header.id, eventId, field: header, + isDraggable, linkValues, timelineId, truncate: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx index 5f08bf5a016f59..815f8f43d5c14a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/eql_tab_content/index.test.tsx @@ -60,6 +60,7 @@ jest.mock('../../../../common/lib/kibana', () => { }, timelines: { getLastUpdated: jest.fn(), + getFieldBrowser: jest.fn(), getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, }, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 2a253087567a74..f68538703951af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -376,6 +376,10 @@ export const FooterComponent = ({ + + + + {isLive ? ( @@ -407,10 +411,6 @@ export const FooterComponent = ({ /> )} - - - - ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx index f4d5570ce40d3f..b8e99718fa9333 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pinned_tab_content/index.test.tsx @@ -60,6 +60,7 @@ jest.mock('../../../../common/lib/kibana', () => { }, timelines: { getLastUpdated: jest.fn(), + getFieldBrowser: jest.fn(), getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, }, }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 9bf7ee28f39341..cd9693313b4f90 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -62,6 +62,7 @@ jest.mock('../../../../common/lib/kibana', () => { timelines: { getLastUpdated: jest.fn(), getLoadingPanel: jest.fn(), + getFieldBrowser: jest.fn(), getUseDraggableKeyboardWrapper: () => jest.fn().mockReturnValue({ onBlur: jest.fn(), diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 8018a2f050fc3d..a1c6601520a546 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -64,6 +64,12 @@ export const configSchema = schema.object({ * Artifacts Configuration */ packagerTaskInterval: schema.string({ defaultValue: '60s' }), + + /** + * Detection prebuilt rules + */ + prebuiltRulesFromFileSystem: schema.boolean({ defaultValue: true }), + prebuiltRulesFromSavedObjects: schema.boolean({ defaultValue: true }), }); export const createConfig = (context: PluginInitializerContext) => diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 3ab0e6179f8425..a4d900c5141909 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -94,6 +94,8 @@ export class EndpointAppContextService { this.manifestManager, dependencies.appClientFactory, dependencies.config.maxTimelineImportExportSize, + dependencies.config.prebuiltRulesFromFileSystem, + dependencies.config.prebuiltRulesFromSavedObjects, dependencies.security, dependencies.alerting, dependencies.licenseService, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index 1edcef6dec7224..56c462de54c520 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -42,6 +42,8 @@ describe('ingest_integration tests ', () => { let ctx: SecuritySolutionRequestHandlerContext; const exceptionListClient: ExceptionListClient = getExceptionListClientMock(); const maxTimelineImportExportSize = createMockConfig().maxTimelineImportExportSize; + const prebuiltRulesFromFileSystem = createMockConfig().prebuiltRulesFromFileSystem; + const prebuiltRulesFromSavedObjects = createMockConfig().prebuiltRulesFromSavedObjects; let licenseEmitter: Subject; let licenseService: LicenseService; const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); @@ -80,6 +82,8 @@ describe('ingest_integration tests ', () => { manifestManager, endpointAppContextMock.appClientFactory, maxTimelineImportExportSize, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects, endpointAppContextMock.security, endpointAppContextMock.alerting, licenseService, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts index 9e1bb2f9b32b0b..3e12fcac52a940 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.ts @@ -34,6 +34,8 @@ export const getPackagePolicyCreateCallback = ( manifestManager: ManifestManager, appClientFactory: AppClientFactory, maxTimelineImportExportSize: number, + prebuiltRulesFromFileSystem: boolean, + prebuiltRulesFromSavedObjects: boolean, securityStart: SecurityPluginStart, alerts: AlertsStartContract, licenseService: LicenseService, @@ -61,6 +63,8 @@ export const getPackagePolicyCreateCallback = ( securityStart, alerts, maxTimelineImportExportSize, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects, exceptionsClient, }), diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts index a387b7e3fdca5e..02815d7e214f77 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/install_prepackaged_rules.ts @@ -22,6 +22,8 @@ export interface InstallPrepackagedRulesProps { securityStart: SecurityPluginStart; alerts: AlertsStartContract; maxTimelineImportExportSize: number; + prebuiltRulesFromFileSystem: boolean; + prebuiltRulesFromSavedObjects: boolean; exceptionsClient: ExceptionListClient; } @@ -37,6 +39,8 @@ export const installPrepackagedRules = async ({ securityStart, alerts, maxTimelineImportExportSize, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects, exceptionsClient, }: InstallPrepackagedRulesProps): Promise => { // prep for detection rules creation @@ -70,6 +74,8 @@ export const installPrepackagedRules = async ({ alerts.getAlertsClientWithRequest(request), frameworkRequest, maxTimelineImportExportSize, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects, exceptionsClient ); } catch (err) { diff --git a/x-pack/plugins/security_solution/server/lib/compose/kibana.ts b/x-pack/plugins/security_solution/server/lib/compose/kibana.ts deleted file mode 100644 index 9be922ecf8db26..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/compose/kibana.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreSetup } from '../../../../../../src/core/server'; -import { SetupPlugins } from '../../plugin'; - -import { KibanaBackendFrameworkAdapter } from '../framework/kibana_framework_adapter'; - -import { ElasticsearchIndexFieldAdapter, IndexFields } from '../index_fields'; - -import { ElasticsearchSourceStatusAdapter, SourceStatus } from '../source_status'; -import { ConfigurationSourcesAdapter, Sources } from '../sources'; -import { AppBackendLibs, AppDomainLibs } from '../types'; -import { note, pinnedEvent, timeline } from '../timeline/saved_object'; -import { EndpointAppContext } from '../../endpoint/types'; - -export function compose( - core: CoreSetup, - plugins: SetupPlugins, - endpointContext: EndpointAppContext -): AppBackendLibs { - const framework = new KibanaBackendFrameworkAdapter(); - const sources = new Sources(new ConfigurationSourcesAdapter()); - const sourceStatus = new SourceStatus(new ElasticsearchSourceStatusAdapter(framework)); - - const domainLibs: AppDomainLibs = { - fields: new IndexFields(new ElasticsearchIndexFieldAdapter()), - }; - - const libs: AppBackendLibs = { - framework, - sourceStatus, - sources, - ...domainLibs, - timeline, - note, - pinnedEvent, - }; - - return libs; -} diff --git a/x-pack/plugins/security_solution/server/lib/configuration/inmemory_configuration_adapter.ts b/x-pack/plugins/security_solution/server/lib/configuration/inmemory_configuration_adapter.ts deleted file mode 100644 index e0418a6ed061a4..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/configuration/inmemory_configuration_adapter.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ConfigurationAdapter } from './adapter_types'; - -export class InmemoryConfigurationAdapter - implements ConfigurationAdapter { - constructor(private readonly configuration: Configuration) {} - - public async get() { - return this.configuration; - } -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.ts index 0a32e0c21a075a..50ad98865544ea 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/types.ts @@ -101,12 +101,25 @@ export type NotificationExecutorOptions = AlertExecutorOptions< // since we are only increasing the strictness of params. export const isNotificationAlertExecutor = ( obj: NotificationAlertTypeDefinition -): obj is AlertType => { +): obj is AlertType< + AlertTypeParams, + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext +> => { return true; }; export type NotificationAlertTypeDefinition = Omit< - AlertType, + AlertType< + AlertTypeParams, + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + 'default' + >, 'executor' > & { executor: ({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/privileges/read_privileges.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/privileges/read_privileges.ts deleted file mode 100644 index bb0c5456c5f401..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/privileges/read_privileges.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CallWithRequest } from '../types'; - -export const readPrivileges = async ( - callWithRequest: CallWithRequest<{}, unknown>, - index: string -): Promise => { - return callWithRequest('transport.request', { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - cluster: [ - 'all', - 'create_snapshot', - 'manage', - 'manage_api_key', - 'manage_ccr', - 'manage_transform', - 'manage_ilm', - 'manage_index_templates', - 'manage_ingest_pipelines', - 'manage_ml', - 'manage_own_api_key', - 'manage_pipeline', - 'manage_rollup', - 'manage_saml', - 'manage_security', - 'manage_token', - 'manage_watcher', - 'monitor', - 'monitor_transform', - 'monitor_ml', - 'monitor_rollup', - 'monitor_watcher', - 'read_ccr', - 'read_ilm', - 'transport_client', - ], - index: [ - { - names: [index], - privileges: [ - 'all', - 'create', - 'create_doc', - 'create_index', - 'delete', - 'delete_index', - 'index', - 'manage', - 'maintenance', - 'manage_follow_index', - 'manage_ilm', - 'manage_leader_index', - 'monitor', - 'read', - 'read_cross_cluster', - 'view_index_metadata', - 'write', - ], - }, - ], - }, - }); -}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index f376b353531c30..a768273c9d147c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -26,6 +26,8 @@ export const createMockConfig = (): ConfigType => ({ endpointResultListDefaultPageSize: 10, packagerTaskInterval: '60s', alertMergeStrategy: 'missingFields', + prebuiltRulesFromFileSystem: true, + prebuiltRulesFromSavedObjects: false, }); export const mockGetCurrentUser = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index 533b3f86728f1d..2e33200ee73908 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -17,16 +17,34 @@ import { siemMock } from '../../../../mocks'; const createMockClients = () => ({ alertsClient: alertsClientMock.create(), - clusterClient: elasticsearchServiceMock.createLegacyScopedClusterClient(), licensing: { license: licensingMock.createLicenseMock() }, - newClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + clusterClient: elasticsearchServiceMock.createScopedClusterClient(), savedObjectsClient: savedObjectsClientMock.create(), appClient: siemMock.createClient(), }); +/** + * Adds mocking to the interface so we don't have to cast everywhere + */ +type SecuritySolutionRequestHandlerContextMock = SecuritySolutionRequestHandlerContext & { + core: { + elasticsearch: { + client: { + asCurrentUser: { + updateByQuery: jest.Mock; + search: jest.Mock; + transport: { + request: jest.Mock; + }; + }; + }; + }; + }; +}; + const createRequestContextMock = ( clients: ReturnType = createMockClients() -) => { +): SecuritySolutionRequestHandlerContextMock => { const coreContext = coreMock.createRequestHandlerContext(); return ({ alerting: { getAlertsClient: jest.fn(() => clients.alertsClient) }, @@ -34,14 +52,13 @@ const createRequestContextMock = ( ...coreContext, elasticsearch: { ...coreContext.elasticsearch, - client: clients.newClusterClient, - legacy: { ...coreContext.elasticsearch.legacy, client: clients.clusterClient }, + client: clients.clusterClient, }, savedObjects: { client: clients.savedObjectsClient }, }, licensing: clients.licensing, securitySolution: { getAppClient: jest.fn(() => clients.appClient) }, - } as unknown) as SecuritySolutionRequestHandlerContext; + } as unknown) as SecuritySolutionRequestHandlerContextMock; }; const createTools = () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 3942d1637fedd1..8959c2b89d2b6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -20,7 +20,6 @@ import { DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, DETECTION_ENGINE_RULES_BULK_ACTION, } from '../../../../../common/constants'; -import { ShardsResponse } from '../../../types'; import { RuleAlertType, IRuleSavedAttributesSavedObjectAttributes, @@ -50,6 +49,7 @@ export const typicalSetStatusSignalByQueryPayload = (): SetSignalsStatusSchemaDe }); export const typicalSignalsQuery = (): QuerySignalsSchemaDecoded => ({ + aggs: {}, query: { match_all: {} }, }); @@ -608,14 +608,6 @@ export const getSuccessfulSignalUpdateResponse = () => ({ failures: [], }); -export const getIndexName = () => 'index-name'; -export const getEmptyIndex = (): { _shards: Partial } => ({ - _shards: { total: 0 }, -}); -export const getNonEmptyIndex = (): { _shards: Partial } => ({ - _shards: { total: 1 }, -}); - export const getNotificationResult = (): RuleNotificationAlertType => ({ id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', name: 'Notification for Rule Test', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts index 2efb65c4a49a24..b79bdc857a171c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts @@ -8,16 +8,21 @@ import { readPrivilegesRoute } from './read_privileges_route'; import { serverMock, requestContextMock } from '../__mocks__'; import { getPrivilegeRequest, getMockPrivilegesResult } from '../__mocks__/request_responses'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; describe('read_privileges route', () => { let server: ReturnType; - let { clients, context } = requestContextMock.createTools(); + let { context } = requestContextMock.createTools(); beforeEach(() => { server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); + ({ context } = requestContextMock.createTools()); + + context.core.elasticsearch.client.asCurrentUser.transport.request.mockResolvedValue({ + body: getMockPrivilegesResult(), + }); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getMockPrivilegesResult()); readPrivilegesRoute(server.router, true); }); @@ -60,9 +65,9 @@ describe('read_privileges route', () => { }); test('returns 500 when bad response from cluster', async () => { - clients.clusterClient.callAsCurrentUser.mockImplementation(() => { - throw new Error('Test error'); - }); + context.core.elasticsearch.client.asCurrentUser.transport.request.mockResolvedValue( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) + ); const response = await server.inject( getPrivilegeRequest({ auth: { isAuthenticated: false } }), context diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index 04fd2aeaebb2d4..2c86b5e2f03262 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -7,13 +7,11 @@ import { merge } from 'lodash/fp'; -import { transformError } from '@kbn/securitysolution-es-utils'; +import { readPrivileges, transformError } from '@kbn/securitysolution-es-utils'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { DETECTION_ENGINE_PRIVILEGES_URL } from '../../../../../common/constants'; import { buildSiemResponse } from '../utils'; -import { readPrivileges } from '../../privileges/read_privileges'; - export const readPrivilegesRoute = ( router: SecuritySolutionPluginRouter, hasEncryptionKey: boolean @@ -30,7 +28,7 @@ export const readPrivilegesRoute = ( const siemResponse = buildSiemResponse(response); try { - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; const siemClient = context.securitySolution?.getAppClient(); if (!siemClient) { @@ -38,7 +36,7 @@ export const readPrivilegesRoute = ( } const index = siemClient.getSignalsIndex(); - const clusterPrivileges = await readPrivileges(clusterClient.callAsCurrentUser, index); + const clusterPrivileges = await readPrivileges(esClient, index); const privileges = merge(clusterPrivileges, { is_authenticated: request.auth.isAuthenticated ?? false, has_encryption_key: hasEncryptionKey, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 026820a8f2ff76..3f0faf4da6e8b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -9,7 +9,6 @@ import { getEmptyFindResult, addPrepackagedRulesRequest, getFindResultWithSingleHit, - getNonEmptyIndex, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, createMockConfig, mockGetCurrentUser } from '../__mocks__'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; @@ -90,7 +89,6 @@ describe('add_prepackaged_rules_route', () => { mockExceptionsClient = listMock.getExceptionListClient(); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); (installPrepackagedTimelines as jest.Mock).mockReset(); @@ -102,8 +100,7 @@ describe('add_prepackaged_rules_route', () => { errors: [], }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) ); addPrepackedRulesRoute(server.router, createMockConfig(), securitySetup); @@ -131,8 +128,7 @@ describe('add_prepackaged_rules_route', () => { test('it returns a 400 if the index does not exist', async () => { const request = addPrepackagedRulesRequest(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) ); const response = await server.inject(request, context); @@ -187,8 +183,7 @@ describe('add_prepackaged_rules_route', () => { }); test('catches errors if payloads cause errors to be thrown', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) ); const request = addPrepackagedRulesRequest(); @@ -297,6 +292,7 @@ describe('add_prepackaged_rules_route', () => { getExceptionListClient: jest.fn(), getListClient: jest.fn(), }; + const config = createMockConfig(); await createPrepackagedRules( context, @@ -304,6 +300,8 @@ describe('add_prepackaged_rules_route', () => { clients.alertsClient, {} as FrameworkRequest, 1200, + config.prebuiltRulesFromFileSystem, + config.prebuiltRulesFromSavedObjects, mockExceptionsClient ); @@ -313,6 +311,7 @@ describe('add_prepackaged_rules_route', () => { test('uses passed in exceptions list client when lists client not available in context', async () => { const { lists, ...myContext } = context; + const config = createMockConfig(); await createPrepackagedRules( myContext, @@ -320,6 +319,8 @@ describe('add_prepackaged_rules_route', () => { clients.alertsClient, {} as FrameworkRequest, 1200, + config.prebuiltRulesFromFileSystem, + config.prebuiltRulesFromSavedObjects, mockExceptionsClient ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 03d357ab10bb95..b62034128de3e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -77,7 +77,9 @@ export const addPrepackedRulesRoute = ( siemClient, alertsClient, frameworkRequest, - config.maxTimelineImportExportSize + config.maxTimelineImportExportSize, + config.prebuiltRulesFromFileSystem, + config.prebuiltRulesFromSavedObjects ); return response.ok({ body: validated ?? {} }); } catch (err) { @@ -104,7 +106,9 @@ export const createPrepackagedRules = async ( siemClient: AppClient, alertsClient: AlertsClient, frameworkRequest: FrameworkRequest, - maxTimelineImportExportSize: number, + maxTimelineImportExportSize: ConfigType['maxTimelineImportExportSize'], + prebuiltRulesFromFileSystem: ConfigType['prebuiltRulesFromFileSystem'], + prebuiltRulesFromSavedObjects: ConfigType['prebuiltRulesFromSavedObjects'], exceptionsClient?: ExceptionListClient ): Promise => { const esClient = context.core.elasticsearch.client; @@ -121,7 +125,11 @@ export const createPrepackagedRules = async ( await exceptionsListClient.createEndpointList(); } - const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); + const latestPrepackagedRules = await getLatestPrepackagedRules( + ruleAssetsClient, + prebuiltRulesFromFileSystem, + prebuiltRulesFromSavedObjects + ); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); const rulesToInstall = getRulesToInstall(latestPrepackagedRules, prepackagedRules); const rulesToUpdate = getRulesToUpdate(latestPrepackagedRules, prepackagedRules); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index 311e2fcc41a0b8..bbb753f1f62de2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -10,7 +10,6 @@ import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../mach import { buildMlAuthz } from '../../../machine_learning/authz'; import { getReadBulkRequest, - getNonEmptyIndex, getFindResultWithSingleHit, getEmptyFindResult, getAlertMock, @@ -35,12 +34,10 @@ describe('create_rules_bulk', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // successful creation - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) ); createRulesBulkRoute(server.router, ml); @@ -90,8 +87,7 @@ describe('create_rules_bulk', () => { }); it('returns an error object if the index does not exist', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) ); const response = await server.inject(getReadBulkRequest(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index b04f178363f998..6b0b01a9a9de90 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -11,7 +11,6 @@ import { getAlertMock, getCreateRequest, getFindResultStatus, - getNonEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, } from '../__mocks__/request_responses'; @@ -37,13 +36,11 @@ describe('create_rules', () => { ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules clients.alertsClient.create.mockResolvedValue(getAlertMock(getQueryRuleParams())); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) ); createRulesRoute(server.router, ml); @@ -108,8 +105,7 @@ describe('create_rules', () => { describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) ); const response = await server.inject(getCreateRequest(), context); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 993d9300e414f4..4b78586ba739ba 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -26,7 +26,7 @@ import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters' export const createRulesRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'], - ruleDataClient?: RuleDataClient | null + ruleDataClient?: RuleDataClient | null // TODO: Use this for RAC (otherwise delete it) ): void => { router.post( { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 3c8321ee8eb9a5..f88da36db4491c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -11,7 +11,6 @@ import { getEmptyFindResult, getFindResultWithSingleHit, getPrepackagedRulesStatusRequest, - getNonEmptyIndex, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, createMockConfig } from '../__mocks__'; import { SecurityPluginSetup } from '../../../../../../security/server'; @@ -74,7 +73,6 @@ describe('get_prepackaged_rule_status_route', () => { authz: {}, } as unknown) as SecurityPluginSetup; - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); getPrepackagedRulesStatusRoute(server.router, createMockConfig(), securitySetup); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index cd02cc72ba40ca..a20152a07ef15c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -53,7 +53,11 @@ export const getPrepackagedRulesStatusRoute = ( } try { - const latestPrepackagedRules = await getLatestPrepackagedRules(ruleAssetsClient); + const latestPrepackagedRules = await getLatestPrepackagedRules( + ruleAssetsClient, + config.prebuiltRulesFromFileSystem, + config.prebuiltRulesFromSavedObjects + ); const customRules = await findRules({ alertsClient, perPage: 1, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 0a680d1b0d1c11..ab9e6983590c9e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -12,7 +12,6 @@ import { getEmptyFindResult, getAlertMock, getFindResultWithSingleHit, - getNonEmptyIndex, } from '../__mocks__/request_responses'; import { createMockConfig, requestContextMock, serverMock, requestMock } from '../__mocks__'; import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; @@ -45,11 +44,9 @@ describe('import_rules_route', () => { request = getImportRulesRequest(hapiStream); ml = mlServicesMock.createSetupContract(); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 1 } }) ); importRulesRoute(server.router, config, ml); @@ -130,8 +127,7 @@ describe('import_rules_route', () => { test('returns an error if the index does not exist', async () => { clients.appClient.getSignalsIndex.mockReturnValue('mockSignalsIndex'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValueOnce( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ _shards: { total: 0 } }) ); const response = await server.inject(request, context); @@ -144,8 +140,7 @@ describe('import_rules_route', () => { }); test('returns an error when cluster throws error', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (context.core.elasticsearch.client.asCurrentUser.search as any).mockResolvedValue( + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( elasticsearchClientMock.createErrorTransportRequestPromise({ body: new Error('Test error'), }) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts index 9a9f8e949fab7f..f6abfc9ebe3d16 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals.test.ts @@ -16,17 +16,21 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { setSignalsStatusRoute } from './open_close_signals_route'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; describe('set signal status', () => { let server: ReturnType; - let { clients, context } = requestContextMock.createTools(); + let { context } = requestContextMock.createTools(); beforeEach(() => { server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); - - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getSuccessfulSignalUpdateResponse()); - + ({ context } = requestContextMock.createTools()); + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + getSuccessfulSignalUpdateResponse() + ) + ); setSignalsStatusRoute(server.router); }); @@ -52,10 +56,10 @@ describe('set signal status', () => { expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); - test('catches error if callAsCurrentUser throws error', async () => { - clients.clusterClient.callAsCurrentUser.mockImplementation(async () => { - throw new Error('Test error'); - }); + test('catches error if asCurrentUser throws error', async () => { + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResolvedValue( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) + ); const response = await server.inject(getSetSignalStatusByQueryRequest(), context); expect(response.status).toEqual(500); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index fd001595fb9c75..bf21f9de037f4a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -32,7 +32,7 @@ export const setSignalsStatusRoute = (router: SecuritySolutionPluginRouter) => { }, async (context, request, response) => { const { conflicts, signal_ids: signalIds, query, status } = request.body; - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; const siemClient = context.securitySolution?.getAppClient(); const siemResponse = buildSiemResponse(response); const validationErrors = setSignalStatusValidateTypeDependents(request.body); @@ -57,10 +57,13 @@ export const setSignalsStatusRoute = (router: SecuritySolutionPluginRouter) => { }; } try { - const result = await clusterClient.callAsCurrentUser('updateByQuery', { + const { body } = await esClient.updateByQuery({ index: siemClient.getSignalsIndex(), conflicts: conflicts ?? 'abort', - refresh: 'wait_for', + // https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update-by-query.html#_refreshing_shards_2 + // Note: Before we tried to use "refresh: wait_for" but I do not think that was available and instead it defaulted to "refresh: true" + // but the tests do not pass with "refresh: false". If at some point a "refresh: wait_for" is implemented, we should use that instead. + refresh: true, body: { script: { source: `ctx._source.signal.status = '${status}'`, @@ -68,9 +71,9 @@ export const setSignalsStatusRoute = (router: SecuritySolutionPluginRouter) => { }, query: queryObject, }, - ignoreUnavailable: true, + ignore_unavailable: true, }); - return response.ok({ body: result }); + return response.ok({ body }); } catch (err) { // error while getting or updating signal with id: id in signal index .siem-signals const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.test.ts index d6b998e3142349..dd181476a48904 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.test.ts @@ -16,16 +16,20 @@ import { } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock, createMockConfig } from '../__mocks__'; import { querySignalsRoute } from './query_signals_route'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; describe('query for signal', () => { let server: ReturnType; - let { clients, context } = requestContextMock.createTools(); + let { context } = requestContextMock.createTools(); beforeEach(() => { server = serverMock.create(); - ({ clients, context } = requestContextMock.createTools()); + ({ context } = requestContextMock.createTools()); - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptySignalsResponse()); + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(getEmptySignalsResponse()) + ); querySignalsRoute(server.router, createMockConfig()); }); @@ -35,9 +39,10 @@ describe('query for signal', () => { const response = await server.inject(getSignalsQueryRequest(), context); expect(response.status).toEqual(200); - expect(clients.clusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'search', - expect.objectContaining({ body: typicalSignalsQuery() }) + expect(context.core.elasticsearch.client.asCurrentUser.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: typicalSignalsQuery(), + }) ); }); @@ -45,9 +50,8 @@ describe('query for signal', () => { const response = await server.inject(getSignalsAggsQueryRequest(), context); expect(response.status).toEqual(200); - expect(clients.clusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'search', - expect.objectContaining({ body: typicalSignalsQueryAggs() }) + expect(context.core.elasticsearch.client.asCurrentUser.search).toHaveBeenCalledWith( + expect.objectContaining({ body: typicalSignalsQueryAggs(), ignore_unavailable: true }) ); }); @@ -55,8 +59,7 @@ describe('query for signal', () => { const response = await server.inject(getSignalsAggsAndQueryRequest(), context); expect(response.status).toEqual(200); - expect(clients.clusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'search', + expect(context.core.elasticsearch.client.asCurrentUser.search).toHaveBeenCalledWith( expect.objectContaining({ body: { ...typicalSignalsQuery(), @@ -67,9 +70,9 @@ describe('query for signal', () => { }); test('catches error if query throws error', async () => { - clients.clusterClient.callAsCurrentUser.mockImplementation(async () => { - throw new Error('Test error'); - }); + context.core.elasticsearch.client.asCurrentUser.search.mockResolvedValue( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('Test error')) + ); const response = await server.inject(getSignalsAggsQueryRequest(), context); expect(response.status).toEqual(500); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts index 770c1a5da344f6..279a824426cec0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/query_signals_route.ts @@ -50,19 +50,26 @@ export const querySignalsRoute = (router: SecuritySolutionPluginRouter, config: body: '"value" must have at least 1 children', }); } - const clusterClient = context.core.elasticsearch.legacy.client; + const esClient = context.core.elasticsearch.client.asCurrentUser; const siemClient = context.securitySolution!.getAppClient(); // TODO: Once we are past experimental phase this code should be removed const { ruleRegistryEnabled } = parseExperimentalConfigValue(config.enableExperimental); try { - const result = await clusterClient.callAsCurrentUser('search', { + const { body } = await esClient.search({ index: ruleRegistryEnabled ? DEFAULT_ALERTS_INDEX : siemClient.getSignalsIndex(), - body: { query, aggs, _source, track_total_hits, size }, - ignoreUnavailable: true, + body: { + query, + // Note: I use a spread operator to please TypeScript with aggs: { ...aggs } + aggs: { ...aggs }, + _source, + track_total_hits, + size, + }, + ignore_unavailable: true, }); - return response.ok({ body: result }); + return response.ok({ body }); } catch (err) { // error while getting or updating signal with id: id in signal index .siem-signals const error = transformError(err); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts index f2d28d13fa9266..6fe326a8d85a32 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_prepackaged_rules.ts @@ -21,6 +21,7 @@ import { rawRules } from './prepackaged_rules'; import { RuleAssetSavedObjectsClient } from './rule_asset_saved_objects_client'; import { IRuleAssetSOAttributes } from './types'; import { SavedObjectAttributes } from '../../../../../../../src/core/types'; +import { ConfigType } from '../../../config'; /** * Validate the rules from the file system and throw any errors indicating to the developer @@ -103,21 +104,25 @@ export const getPrepackagedRules = ( }; export const getLatestPrepackagedRules = async ( - client: RuleAssetSavedObjectsClient + client: RuleAssetSavedObjectsClient, + prebuiltRulesFromFileSystem: ConfigType['prebuiltRulesFromFileSystem'], + prebuiltRulesFromSavedObjects: ConfigType['prebuiltRulesFromSavedObjects'] ): Promise => { // build a map of the most recent version of each rule - const prepackaged = getPrepackagedRules(); + const prepackaged = prebuiltRulesFromFileSystem ? getPrepackagedRules() : []; const ruleMap = new Map(prepackaged.map((r) => [r.rule_id, r])); // check the rules installed via fleet and create/update if the version is newer - const fleetRules = await getFleetInstalledRules(client); - const fleetUpdates = fleetRules.filter((r) => { - const rule = ruleMap.get(r.rule_id); - return rule == null || rule.version < r.version; - }); + if (prebuiltRulesFromSavedObjects) { + const fleetRules = await getFleetInstalledRules(client); + const fleetUpdates = fleetRules.filter((r) => { + const rule = ruleMap.get(r.rule_id); + return rule == null || rule.version < r.version; + }); - // add the new or updated rules to the map - fleetUpdates.forEach((r) => ruleMap.set(r.rule_id, r)); + // add the new or updated rules to the map + fleetUpdates.forEach((r) => ruleMap.set(r.rule_id, r)); + } return Array.from(ruleMap.values()); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 3fc36d5930a0aa..edf6d244cfa174 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -184,6 +184,7 @@ export const isAlertExecutor = ( obj: SignalRuleAlertTypeDefinition ): obj is AlertType< RuleParams, + never, // Only use if defining useSavedObjectReferences hook AlertTypeState, AlertInstanceState, AlertInstanceContext, @@ -194,6 +195,7 @@ export const isAlertExecutor = ( export type SignalRuleAlertTypeDefinition = AlertType< RuleParams, + never, // Only use if defining useSavedObjectReferences hook AlertTypeState, AlertInstanceState, AlertInstanceContext, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 1b80a9b6b02e2c..9eb160ed2da560 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -52,7 +52,6 @@ import { EventCategoryOverrideOrUndefined, } from '../../../common/detection_engine/schemas/common/schemas'; -import { LegacyCallAPIOptions } from '../../../../../../src/core/server'; import { Filter } from '../../../../../../src/plugins/data/server'; import { AlertTypeParams } from '../../../../alerting/common'; @@ -104,11 +103,4 @@ export interface RuleTypeParams extends AlertTypeParams { itemsPerSearch?: ItemsPerSearchOrUndefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type CallWithRequest, V> = ( - endpoint: string, - params: T, - options?: LegacyCallAPIOptions -) => Promise; - export type RefreshTypes = false | 'wait_for'; diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.test.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.test.ts deleted file mode 100644 index e27b15f021257c..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { extendMap } from './extend_map'; - -describe('ecs_fields test', () => { - describe('extendMap', () => { - test('it should extend a record', () => { - const osFieldsMap: Readonly> = { - 'os.platform': 'os.platform', - 'os.full': 'os.full', - 'os.family': 'os.family', - 'os.version': 'os.version', - 'os.kernel': 'os.kernel', - }; - const expected: Record = { - 'host.os.family': 'host.os.family', - 'host.os.full': 'host.os.full', - 'host.os.kernel': 'host.os.kernel', - 'host.os.platform': 'host.os.platform', - 'host.os.version': 'host.os.version', - }; - expect(extendMap('host', osFieldsMap)).toEqual(expected); - }); - - test('it should extend a sample hosts record', () => { - const hostMap: Record = { - 'host.id': 'host.id', - 'host.ip': 'host.ip', - 'host.name': 'host.name', - }; - const osFieldsMap: Readonly> = { - 'os.platform': 'os.platform', - 'os.full': 'os.full', - 'os.family': 'os.family', - 'os.version': 'os.version', - 'os.kernel': 'os.kernel', - }; - const expected: Record = { - 'host.id': 'host.id', - 'host.ip': 'host.ip', - 'host.name': 'host.name', - 'host.os.family': 'host.os.family', - 'host.os.full': 'host.os.full', - 'host.os.kernel': 'host.os.kernel', - 'host.os.platform': 'host.os.platform', - 'host.os.version': 'host.os.version', - }; - const output = { ...hostMap, ...extendMap('host', osFieldsMap) }; - expect(output).toEqual(expected); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.ts deleted file mode 100644 index 184e6b4f325665..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/extend_map.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const extendMap = ( - path: string, - map: Readonly> -): Readonly> => - Object.entries(map).reduce>((accum, [key, value]) => { - accum[`${path}.${key}`] = `${path}.${value}`; - return accum; - }, {}); diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts deleted file mode 100644 index 7e9e4e8cd37bd3..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts +++ /dev/null @@ -1,361 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { extendMap } from './extend_map'; - -export const auditdMap: Readonly> = { - 'auditd.result': 'auditd.result', - 'auditd.session': 'auditd.session', - 'auditd.data.acct': 'auditd.data.acct', - 'auditd.data.terminal': 'auditd.data.terminal', - 'auditd.data.op': 'auditd.data.op', - 'auditd.summary.actor.primary': 'auditd.summary.actor.primary', - 'auditd.summary.actor.secondary': 'auditd.summary.actor.secondary', - 'auditd.summary.object.primary': 'auditd.summary.object.primary', - 'auditd.summary.object.secondary': 'auditd.summary.object.secondary', - 'auditd.summary.object.type': 'auditd.summary.object.type', - 'auditd.summary.how': 'auditd.summary.how', - 'auditd.summary.message_type': 'auditd.summary.message_type', - 'auditd.summary.sequence': 'auditd.summary.sequence', -}; - -export const cloudFieldsMap: Readonly> = { - 'cloud.account.id': 'cloud.account.id', - 'cloud.availability_zone': 'cloud.availability_zone', - 'cloud.instance.id': 'cloud.instance.id', - 'cloud.instance.name': 'cloud.instance.name', - 'cloud.machine.type': 'cloud.machine.type', - 'cloud.provider': 'cloud.provider', - 'cloud.region': 'cloud.region', -}; - -export const fileMap: Readonly> = { - 'file.name': 'file.name', - 'file.path': 'file.path', - 'file.target_path': 'file.target_path', - 'file.extension': 'file.extension', - 'file.type': 'file.type', - 'file.device': 'file.device', - 'file.inode': 'file.inode', - 'file.uid': 'file.uid', - 'file.owner': 'file.owner', - 'file.gid': 'file.gid', - 'file.group': 'file.group', - 'file.mode': 'file.mode', - 'file.size': 'file.size', - 'file.mtime': 'file.mtime', - 'file.ctime': 'file.ctime', -}; - -export const osFieldsMap: Readonly> = { - 'os.platform': 'os.platform', - 'os.name': 'os.name', - 'os.full': 'os.full', - 'os.family': 'os.family', - 'os.version': 'os.version', - 'os.kernel': 'os.kernel', -}; - -export const hostFieldsMap: Readonly> = { - 'host.architecture': 'host.architecture', - 'host.id': 'host.id', - 'host.ip': 'host.ip', - 'host.mac': 'host.mac', - 'host.name': 'host.name', - ...extendMap('host', osFieldsMap), -}; - -export const processFieldsMap: Readonly> = { - 'process.hash.md5': 'process.hash.md5', - 'process.hash.sha1': 'process.hash.sha1', - 'process.hash.sha256': 'process.hash.sha256', - 'process.pid': 'process.pid', - 'process.name': 'process.name', - 'process.ppid': 'process.ppid', - 'process.args': 'process.args', - 'process.entity_id': 'process.entity_id', - 'process.executable': 'process.executable', - 'process.title': 'process.title', - 'process.thread': 'process.thread', - 'process.working_directory': 'process.working_directory', -}; - -export const agentFieldsMap: Readonly> = { - 'agent.type': 'agent.type', - 'agent.id': 'agent.id', -}; - -export const userFieldsMap: Readonly> = { - 'user.domain': 'user.domain', - 'user.id': 'user.id', - 'user.name': 'user.name', - // NOTE: This field is not tested and available from ECS. Please remove this tag once it is - 'user.full_name': 'user.full_name', - // NOTE: This field is not tested and available from ECS. Please remove this tag once it is - 'user.email': 'user.email', - // NOTE: This field is not tested and available from ECS. Please remove this tag once it is - 'user.hash': 'user.hash', - // NOTE: This field is not tested and available from ECS. Please remove this tag once it is - 'user.group': 'user.group', -}; - -export const winlogFieldsMap: Readonly> = { - 'winlog.event_id': 'winlog.event_id', -}; - -export const suricataFieldsMap: Readonly> = { - 'suricata.eve.flow_id': 'suricata.eve.flow_id', - 'suricata.eve.proto': 'suricata.eve.proto', - 'suricata.eve.alert.signature': 'suricata.eve.alert.signature', - 'suricata.eve.alert.signature_id': 'suricata.eve.alert.signature_id', -}; - -export const tlsFieldsMap: Readonly> = { - 'tls.client_certificate.fingerprint.sha1': 'tls.client_certificate.fingerprint.sha1', - 'tls.fingerprints.ja3.hash': 'tls.fingerprints.ja3.hash', - 'tls.server_certificate.fingerprint.sha1': 'tls.server_certificate.fingerprint.sha1', -}; - -export const urlFieldsMap: Readonly> = { - 'url.original': 'url.original', - 'url.domain': 'url.domain', - 'user.username': 'user.username', - 'user.password': 'user.password', -}; - -export const httpFieldsMap: Readonly> = { - 'http.version': 'http.version', - 'http.request': 'http.request', - 'http.request.method': 'http.request.method', - 'http.request.body.bytes': 'http.request.body.bytes', - 'http.request.body.content': 'http.request.body.content', - 'http.request.referrer': 'http.request.referrer', - 'http.response.status_code': 'http.response.status_code', - 'http.response.body': 'http.response.body', - 'http.response.body.bytes': 'http.response.body.bytes', - 'http.response.body.content': 'http.response.body.content', -}; - -export const zeekFieldsMap: Readonly> = { - 'zeek.session_id': 'zeek.session_id', - 'zeek.connection.local_resp': 'zeek.connection.local_resp', - 'zeek.connection.local_orig': 'zeek.connection.local_orig', - 'zeek.connection.missed_bytes': 'zeek.connection.missed_bytes', - 'zeek.connection.state': 'zeek.connection.state', - 'zeek.connection.history': 'zeek.connection.history', - 'zeek.notice.suppress_for': 'zeek.notice.suppress_for', - 'zeek.notice.msg': 'zeek.notice.msg', - 'zeek.notice.note': 'zeek.notice.note', - 'zeek.notice.sub': 'zeek.notice.sub', - 'zeek.notice.dst': 'zeek.notice.dst', - 'zeek.notice.dropped': 'zeek.notice.dropped', - 'zeek.notice.peer_descr': 'zeek.notice.peer_descr', - 'zeek.dns.AA': 'zeek.dns.AA', - 'zeek.dns.qclass_name': 'zeek.dns.qclass_name', - 'zeek.dns.RD': 'zeek.dns.RD', - 'zeek.dns.qtype_name': 'zeek.dns.qtype_name', - 'zeek.dns.qtype': 'zeek.dns.qtype', - 'zeek.dns.query': 'zeek.dns.query', - 'zeek.dns.trans_id': 'zeek.dns.trans_id', - 'zeek.dns.qclass': 'zeek.dns.qclass', - 'zeek.dns.RA': 'zeek.dns.RA', - 'zeek.dns.TC': 'zeek.dns.TC', - 'zeek.http.resp_mime_types': 'zeek.http.resp_mime_types', - 'zeek.http.trans_depth': 'zeek.http.trans_depth', - 'zeek.http.status_msg': 'zeek.http.status_msg', - 'zeek.http.resp_fuids': 'zeek.http.resp_fuids', - 'zeek.http.tags': 'zeek.http.tags', - 'zeek.files.session_ids': 'zeek.files.session_ids', - 'zeek.files.timedout': 'zeek.files.timedout', - 'zeek.files.local_orig': 'zeek.files.local_orig', - 'zeek.files.tx_host': 'zeek.files.tx_host', - 'zeek.files.source': 'zeek.files.source', - 'zeek.files.is_orig': 'zeek.files.is_orig', - 'zeek.files.overflow_bytes': 'zeek.files.overflow_bytes', - 'zeek.files.sha1': 'zeek.files.sha1', - 'zeek.files.duration': 'zeek.files.duration', - 'zeek.files.depth': 'zeek.files.depth', - 'zeek.files.analyzers': 'zeek.files.analyzers', - 'zeek.files.mime_type': 'zeek.files.mime_type', - 'zeek.files.rx_host': 'zeek.files.rx_host', - 'zeek.files.total_bytes': 'zeek.files.total_bytes', - 'zeek.files.fuid': 'zeek.files.fuid', - 'zeek.files.seen_bytes': 'zeek.files.seen_bytes', - 'zeek.files.missing_bytes': 'zeek.files.missing_bytes', - 'zeek.files.md5': 'zeek.files.md5', - 'zeek.ssl.cipher': 'zeek.ssl.cipher', - 'zeek.ssl.established': 'zeek.ssl.established', - 'zeek.ssl.resumed': 'zeek.ssl.resumed', - 'zeek.ssl.version': 'zeek.ssl.version', -}; - -export const sourceFieldsMap: Readonly> = { - 'source.bytes': 'source.bytes', - 'source.ip': 'source.ip', - 'source.packets': 'source.packets', - 'source.port': 'source.port', - 'source.domain': 'source.domain', - 'source.geo.continent_name': 'source.geo.continent_name', - 'source.geo.country_name': 'source.geo.country_name', - 'source.geo.country_iso_code': 'source.geo.country_iso_code', - 'source.geo.city_name': 'source.geo.city_name', - 'source.geo.region_iso_code': 'source.geo.region_iso_code', - 'source.geo.region_name': 'source.geo.region_name', -}; - -export const destinationFieldsMap: Readonly> = { - 'destination.bytes': 'destination.bytes', - 'destination.ip': 'destination.ip', - 'destination.packets': 'destination.packets', - 'destination.port': 'destination.port', - 'destination.domain': 'destination.domain', - 'destination.geo.continent_name': 'destination.geo.continent_name', - 'destination.geo.country_name': 'destination.geo.country_name', - 'destination.geo.country_iso_code': 'destination.geo.country_iso_code', - 'destination.geo.city_name': 'destination.geo.city_name', - 'destination.geo.region_iso_code': 'destination.geo.region_iso_code', - 'destination.geo.region_name': 'destination.geo.region_name', -}; - -export const networkFieldsMap: Readonly> = { - 'network.bytes': 'network.bytes', - 'network.community_id': 'network.community_id', - 'network.direction': 'network.direction', - 'network.packets': 'network.packets', - 'network.protocol': 'network.protocol', - 'network.transport': 'network.transport', -}; - -export const geoFieldsMap: Readonly> = { - 'geo.region_name': 'destination.geo.region_name', - 'geo.country_iso_code': 'destination.geo.country_iso_code', -}; - -export const dnsFieldsMap: Readonly> = { - 'dns.question.name': 'dns.question.name', - 'dns.question.type': 'dns.question.type', - 'dns.resolved_ip': 'dns.resolved_ip', - 'dns.response_code': 'dns.response_code', -}; - -export const endgameFieldsMap: Readonly> = { - 'endgame.exit_code': 'endgame.exit_code', - 'endgame.file_name': 'endgame.file_name', - 'endgame.file_path': 'endgame.file_path', - 'endgame.logon_type': 'endgame.logon_type', - 'endgame.parent_process_name': 'endgame.parent_process_name', - 'endgame.pid': 'endgame.pid', - 'endgame.process_name': 'endgame.process_name', - 'endgame.subject_domain_name': 'endgame.subject_domain_name', - 'endgame.subject_logon_id': 'endgame.subject_logon_id', - 'endgame.subject_user_name': 'endgame.subject_user_name', - 'endgame.target_domain_name': 'endgame.target_domain_name', - 'endgame.target_logon_id': 'endgame.target_logon_id', - 'endgame.target_user_name': 'endgame.target_user_name', -}; - -export const eventBaseFieldsMap: Readonly> = { - 'event.action': 'event.action', - 'event.category': 'event.category', - 'event.code': 'event.code', - 'event.created': 'event.created', - 'event.dataset': 'event.dataset', - 'event.duration': 'event.duration', - 'event.end': 'event.end', - 'event.hash': 'event.hash', - 'event.id': 'event.id', - 'event.kind': 'event.kind', - 'event.module': 'event.module', - 'event.original': 'event.original', - 'event.outcome': 'event.outcome', - 'event.risk_score': 'event.risk_score', - 'event.risk_score_norm': 'event.risk_score_norm', - 'event.severity': 'event.severity', - 'event.start': 'event.start', - 'event.timezone': 'event.timezone', - 'event.type': 'event.type', -}; - -export const systemFieldsMap: Readonly> = { - 'system.audit.package.arch': 'system.audit.package.arch', - 'system.audit.package.entity_id': 'system.audit.package.entity_id', - 'system.audit.package.name': 'system.audit.package.name', - 'system.audit.package.size': 'system.audit.package.size', - 'system.audit.package.summary': 'system.audit.package.summary', - 'system.audit.package.version': 'system.audit.package.version', - 'system.auth.ssh.signature': 'system.auth.ssh.signature', - 'system.auth.ssh.method': 'system.auth.ssh.method', -}; - -export const signalFieldsMap: Readonly> = { - 'signal.original_time': 'signal.original_time', - 'signal.rule.id': 'signal.rule.id', - 'signal.rule.saved_id': 'signal.rule.saved_id', - 'signal.rule.timeline_id': 'signal.rule.timeline_id', - 'signal.rule.timeline_title': 'signal.rule.timeline_title', - 'signal.rule.output_index': 'signal.rule.output_index', - 'signal.rule.from': 'signal.rule.from', - 'signal.rule.index': 'signal.rule.index', - 'signal.rule.language': 'signal.rule.language', - 'signal.rule.query': 'signal.rule.query', - 'signal.rule.to': 'signal.rule.to', - 'signal.rule.filters': 'signal.rule.filters', - 'signal.rule.rule_id': 'signal.rule.rule_id', - 'signal.rule.false_positives': 'signal.rule.false_positives', - 'signal.rule.max_signals': 'signal.rule.max_signals', - 'signal.rule.risk_score': 'signal.rule.risk_score', - 'signal.rule.description': 'signal.rule.description', - 'signal.rule.name': 'signal.rule.name', - 'signal.rule.immutable': 'signal.rule.immutable', - 'signal.rule.references': 'signal.rule.references', - 'signal.rule.severity': 'signal.rule.severity', - 'signal.rule.tags': 'signal.rule.tags', - 'signal.rule.threat': 'signal.rule.threat', - 'signal.rule.type': 'signal.rule.type', - 'signal.rule.size': 'signal.rule.size', - 'signal.rule.enabled': 'signal.rule.enabled', - 'signal.rule.created_at': 'signal.rule.created_at', - 'signal.rule.updated_at': 'signal.rule.updated_at', - 'signal.rule.created_by': 'signal.rule.created_by', - 'signal.rule.updated_by': 'signal.rule.updated_by', - 'signal.rule.version': 'signal.rule.version', - 'signal.rule.note': 'signal.rule.note', - 'signal.rule.threshold': 'signal.rule.threshold', - 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', - 'signal.status': 'signal.status', -}; - -export const ruleFieldsMap: Readonly> = { - 'rule.reference': 'rule.reference', -}; - -export const eventFieldsMap: Readonly> = { - timestamp: '@timestamp', - '@timestamp': '@timestamp', - message: 'message', - ...{ ...agentFieldsMap }, - ...{ ...auditdMap }, - ...{ ...destinationFieldsMap }, - ...{ ...dnsFieldsMap }, - ...{ ...endgameFieldsMap }, - ...{ ...eventBaseFieldsMap }, - ...{ ...fileMap }, - ...{ ...geoFieldsMap }, - ...{ ...hostFieldsMap }, - ...{ ...networkFieldsMap }, - ...{ ...ruleFieldsMap }, - ...{ ...signalFieldsMap }, - ...{ ...sourceFieldsMap }, - ...{ ...suricataFieldsMap }, - ...{ ...systemFieldsMap }, - ...{ ...tlsFieldsMap }, - ...{ ...zeekFieldsMap }, - ...{ ...httpFieldsMap }, - ...{ ...userFieldsMap }, - ...{ ...winlogFieldsMap }, - ...{ ...processFieldsMap }, -}; diff --git a/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts b/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts deleted file mode 100644 index 56c1c802fdd68b..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/framework/kibana_framework_adapter.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { KibanaRequest } from '../../../../../../src/core/server'; -import { IndexPatternsFetcher, UI_SETTINGS } from '../../../../../../src/plugins/data/server'; -import { AuthenticatedUser } from '../../../../security/common/model'; -import type { SecuritySolutionRequestHandlerContext } from '../../types'; - -import { - FrameworkAdapter, - FrameworkIndexPatternsService, - FrameworkRequest, - internalFrameworkRequest, -} from './types'; - -export class KibanaBackendFrameworkAdapter implements FrameworkAdapter { - public async callWithRequest( - req: FrameworkRequest, - endpoint: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - params: Record - ) { - const { elasticsearch, uiSettings } = req.context.core; - const includeFrozen = await uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const maxConcurrentShardRequests = - endpoint === 'msearch' - ? await uiSettings.client.get(UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS) - : 0; - - return elasticsearch.legacy.client.callAsCurrentUser(endpoint, { - ...params, - ignore_throttled: !includeFrozen, - ...(maxConcurrentShardRequests > 0 - ? { max_concurrent_shard_requests: maxConcurrentShardRequests } - : {}), - }); - } - - public getIndexPatternsService(request: FrameworkRequest): FrameworkIndexPatternsService { - return new IndexPatternsFetcher(request.context.core.elasticsearch.client.asCurrentUser, true); - } -} - -export function wrapRequest( - request: KibanaRequest, - context: SecuritySolutionRequestHandlerContext, - user: AuthenticatedUser | null -): FrameworkRequest { - return { - [internalFrameworkRequest]: request, - body: request.body, - context, - user, - }; -} diff --git a/x-pack/plugins/security_solution/server/lib/framework/types.ts b/x-pack/plugins/security_solution/server/lib/framework/types.ts index 6665468a271250..eceff4b35f74f1 100644 --- a/x-pack/plugins/security_solution/server/lib/framework/types.ts +++ b/x-pack/plugins/security_solution/server/lib/framework/types.ts @@ -5,124 +5,14 @@ * 2.0. */ -import { IndicesGetMappingParams } from 'elasticsearch'; - import { KibanaRequest } from '../../../../../../src/core/server'; import { AuthenticatedUser } from '../../../../security/common/model'; -import { ESQuery } from '../../../common/typed_json'; import type { SecuritySolutionRequestHandlerContext } from '../../types'; -import { - DocValueFieldsInput, - PaginationInput, - PaginationInputPaginated, - SortField, - TimerangeInput, -} from '../../../common/search_strategy'; -import { SourceConfiguration } from '../sources'; export const internalFrameworkRequest = Symbol('internalFrameworkRequest'); -export interface FrameworkAdapter { - callWithRequest( - req: FrameworkRequest, - method: 'search', - options?: object - ): Promise>; - callWithRequest( - req: FrameworkRequest, - method: 'msearch', - options?: object - ): Promise>; - callWithRequest( - req: FrameworkRequest, - method: 'indices.getMapping', - options?: IndicesGetMappingParams - ): Promise; - getIndexPatternsService(req: FrameworkRequest): FrameworkIndexPatternsService; -} - export interface FrameworkRequest extends Pick { [internalFrameworkRequest]: KibanaRequest; context: SecuritySolutionRequestHandlerContext; user: AuthenticatedUser | null; } - -export interface DatabaseResponse { - took: number; - timeout: boolean; -} - -export interface DatabaseSearchResponse - extends DatabaseResponse { - _shards: { - total: number; - successful: number; - skipped: number; - failed: number; - }; - aggregations?: Aggregations; - hits: { - total: number; - hits: Hit[]; - }; -} - -export interface DatabaseMultiResponse extends DatabaseResponse { - responses: Array>; -} - -export interface MappingProperties { - type: string; - path: string; - ignore_above: number; - properties: Readonly>>; -} - -export interface MappingResponse { - [indexName: string]: { - mappings: { - _meta: { - beat: string; - version: string; - }; - dynamic_templates: object[]; - date_detection: boolean; - properties: Readonly>>; - }; - }; -} - -interface FrameworkIndexFieldDescriptor { - aggregatable: boolean; - esTypes: string[]; - name: string; - readFromDocValues: boolean; - searchable: boolean; - type: string; -} - -export interface FrameworkIndexPatternsService { - getFieldsForWildcard(options: { - pattern: string | string[]; - }): Promise; -} - -export interface RequestBasicOptions { - sourceConfiguration: SourceConfiguration; - timerange: TimerangeInput; - filterQuery: ESQuery | undefined; - defaultIndex: string[]; - docValueFields?: DocValueFieldsInput[]; -} - -export interface RequestOptions extends RequestBasicOptions { - pagination: PaginationInput; - fields: readonly string[]; - sortField?: SortField; -} - -export interface RequestOptionsPaginated extends RequestBasicOptions { - pagination: PaginationInputPaginated; - fields: readonly string[]; - sortField?: SortField; -} diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.ts deleted file mode 100644 index 81e65fc897d368..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/index_fields/elasticsearch_adapter.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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FrameworkRequest } from '../framework'; -import { FieldsAdapter } from './types'; - -export class ElasticsearchIndexFieldAdapter implements FieldsAdapter { - // Deprecated until we delete all the code - public async getIndexFields(request: FrameworkRequest, indices: string[]): Promise { - return Promise.resolve(['deprecated']); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/index.ts b/x-pack/plugins/security_solution/server/lib/index_fields/index.ts deleted file mode 100644 index 11aba3bf679749..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/index_fields/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FieldsAdapter } from './types'; -import { FrameworkRequest } from '../framework'; -export { ElasticsearchIndexFieldAdapter } from './elasticsearch_adapter'; - -export class IndexFields { - constructor(private readonly adapter: FieldsAdapter) {} - - // Deprecated until we delete all the code - public async getFields(request: FrameworkRequest, defaultIndex: string[]): Promise { - return this.adapter.getIndexFields(request, defaultIndex); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/mock.ts b/x-pack/plugins/security_solution/server/lib/index_fields/mock.ts deleted file mode 100644 index c82f8cf7f916f4..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/index_fields/mock.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IndexFieldDescriptor } from './types'; - -export const mockAuditbeatIndexField: IndexFieldDescriptor[] = [ - { - name: '@timestamp', - searchable: true, - type: 'date', - aggregatable: true, - }, - { - name: 'agent.ephemeral_id', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.name', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.type', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.version', - searchable: true, - type: 'string', - aggregatable: true, - }, -]; - -export const mockFilebeatIndexField: IndexFieldDescriptor[] = [ - { - name: '@timestamp', - searchable: true, - type: 'date', - aggregatable: true, - }, - { - name: 'agent.hostname', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.name', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.version', - searchable: true, - type: 'string', - aggregatable: true, - }, -]; - -export const mockPacketbeatIndexField: IndexFieldDescriptor[] = [ - { - name: '@timestamp', - searchable: true, - type: 'date', - aggregatable: true, - }, - { - name: 'agent.id', - searchable: true, - type: 'string', - aggregatable: true, - }, - { - name: 'agent.type', - searchable: true, - type: 'string', - aggregatable: true, - }, -]; diff --git a/x-pack/plugins/security_solution/server/lib/index_fields/types.ts b/x-pack/plugins/security_solution/server/lib/index_fields/types.ts deleted file mode 100644 index 8426742ed723a4..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/index_fields/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FrameworkRequest } from '../framework'; -import { IFieldSubType } from '../../../../../../src/plugins/data/common'; - -export interface FieldsAdapter { - getIndexFields(req: FrameworkRequest, indices: string[]): Promise; -} - -export interface IndexFieldDescriptor { - name: string; - type: string; - searchable: boolean; - aggregatable: boolean; - esTypes?: string[]; - subType?: IFieldSubType; -} diff --git a/x-pack/plugins/security_solution/server/lib/source_status/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/source_status/elasticsearch_adapter.ts deleted file mode 100644 index 3da0c1675e81eb..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/source_status/elasticsearch_adapter.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FrameworkAdapter, FrameworkRequest } from '../framework'; -import { SourceStatusAdapter } from './index'; -import { buildQuery } from './query.dsl'; -import { ApmServiceNameAgg } from './types'; -import { ENDPOINT_METADATA_INDEX } from '../../../common/constants'; - -const APM_INDEX_NAME = 'apm-*-transaction*'; -const APM_DATA_STREAM = 'traces-apm*'; - -export class ElasticsearchSourceStatusAdapter implements SourceStatusAdapter { - constructor(private readonly framework: FrameworkAdapter) {} - - public async hasIndices(request: FrameworkRequest, indexNames: string[]) { - // Intended flow to determine app-empty state is to first check siem indices (as this is a quick shard count), and - // if no shards exist only then perform the heavier APM query. This optimizes for normal use when siem data exists - try { - // Add endpoint metadata index to indices to check - indexNames.push(ENDPOINT_METADATA_INDEX); - // Remove APM index if exists, and only query if length > 0 in case it's the only index provided - const nonApmIndexNames = indexNames.filter( - (name) => name !== APM_INDEX_NAME && name !== APM_DATA_STREAM - ); - const indexCheckResponse = await (nonApmIndexNames.length > 0 - ? this.framework.callWithRequest(request, 'search', { - index: nonApmIndexNames, - size: 0, - terminate_after: 1, - allow_no_indices: true, - }) - : Promise.resolve(undefined)); - - if ((indexCheckResponse?._shards.total ?? -1) > 0) { - return true; - } - - // Note: Additional check necessary for APM-specific index. For details see: https://github.com/elastic/kibana/issues/56363 - // Only verify if APM data exists if indexNames includes `apm-*-transaction*` (default included apm index) - const includesApmIndex = - indexNames.includes(APM_INDEX_NAME) || indexNames.includes(APM_DATA_STREAM); - const hasApmDataResponse = await (includesApmIndex - ? this.framework.callWithRequest<{}, ApmServiceNameAgg>( - request, - 'search', - buildQuery({ defaultIndex: [APM_INDEX_NAME] }) - ) - : Promise.resolve(undefined)); - - if ((hasApmDataResponse?.aggregations?.total_service_names?.value ?? -1) > 0) { - return true; - } - } catch (e) { - if (e.status === 404) { - return false; - } - throw e; - } - - return false; - } -} diff --git a/x-pack/plugins/security_solution/server/lib/source_status/index.ts b/x-pack/plugins/security_solution/server/lib/source_status/index.ts deleted file mode 100644 index cecccb6e545a7d..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/source_status/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FrameworkRequest } from '../framework'; -export { ElasticsearchSourceStatusAdapter } from './elasticsearch_adapter'; - -export class SourceStatus { - constructor(private readonly adapter: SourceStatusAdapter) {} - - public async hasIndices(request: FrameworkRequest, indexes: string[]): Promise { - return this.adapter.hasIndices(request, indexes); - } -} - -export interface SourceStatusAdapter { - hasIndices(request: FrameworkRequest, indexNames: string[]): Promise; -} diff --git a/x-pack/plugins/security_solution/server/lib/source_status/query.dsl.ts b/x-pack/plugins/security_solution/server/lib/source_status/query.dsl.ts deleted file mode 100644 index 844404614e255b..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/source_status/query.dsl.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -const SERVICE_NAME = 'service.name'; - -export const buildQuery = ({ defaultIndex }: { defaultIndex: string[] }) => { - return { - allowNoIndices: true, - index: defaultIndex, - ignoreUnavailable: true, - terminate_after: 1, - body: { - size: 0, - aggs: { - total_service_names: { - cardinality: { - field: SERVICE_NAME, - }, - }, - }, - }, - track_total_hits: false, - }; -}; diff --git a/x-pack/plugins/security_solution/server/lib/sources/configuration.test.ts b/x-pack/plugins/security_solution/server/lib/sources/configuration.test.ts deleted file mode 100644 index 26bd43fbc4ff1f..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/sources/configuration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { DEFAULT_INDEX_PATTERN } from '../../../common/constants'; -import { InmemoryConfigurationAdapter } from '../configuration/inmemory_configuration_adapter'; -import { ConfigurationSourcesAdapter } from './configuration'; -import { PartialSourceConfiguration } from './types'; - -describe('the ConfigurationSourcesAdapter', () => { - test('adds the default source when no sources are configured', async () => { - const sourcesAdapter = new ConfigurationSourcesAdapter( - new InmemoryConfigurationAdapter({ sources: {} }) - ); - - expect(await sourcesAdapter.getAll()).toMatchObject({ - default: { - fields: { - container: expect.any(String), - host: expect.any(String), - message: expect.arrayContaining([expect.any(String)]), - pod: expect.any(String), - tiebreaker: expect.any(String), - timestamp: expect.any(String), - }, - }, - }); - }); - - test('adds missing aliases to default source when they are missing from the configuration', async () => { - const sourcesAdapter = new ConfigurationSourcesAdapter( - new InmemoryConfigurationAdapter({ - sources: { - default: {} as PartialSourceConfiguration, - }, - }) - ); - - expect(await sourcesAdapter.getAll()).toMatchObject({ - default: {}, - }); - }); - - test('adds missing fields to default source when they are missing from the configuration', async () => { - const sourcesAdapter = new ConfigurationSourcesAdapter( - new InmemoryConfigurationAdapter({ - sources: { - default: { - fields: { - container: 'DIFFERENT_CONTAINER_FIELD', - }, - } as PartialSourceConfiguration, - }, - }) - ); - - expect(await sourcesAdapter.getAll()).toMatchObject({ - default: { - fields: { - container: 'DIFFERENT_CONTAINER_FIELD', - host: expect.any(String), - message: expect.arrayContaining([expect.any(String)]), - pod: expect.any(String), - tiebreaker: expect.any(String), - timestamp: expect.any(String), - }, - }, - }); - }); - - test('adds missing fields to non-default sources when they are missing from the configuration', async () => { - const sourcesAdapter = new ConfigurationSourcesAdapter( - new InmemoryConfigurationAdapter({ - sources: { - sourceOne: { - defaultIndex: DEFAULT_INDEX_PATTERN, - fields: { - container: 'DIFFERENT_CONTAINER_FIELD', - }, - }, - }, - }) - ); - - expect(await sourcesAdapter.getAll()).toMatchObject({ - sourceOne: { - fields: { - container: 'DIFFERENT_CONTAINER_FIELD', - host: expect.any(String), - message: expect.arrayContaining([expect.any(String)]), - pod: expect.any(String), - tiebreaker: expect.any(String), - timestamp: expect.any(String), - }, - }, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/server/lib/sources/configuration.ts b/x-pack/plugins/security_solution/server/lib/sources/configuration.ts deleted file mode 100644 index d6f84e3c27b61b..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/sources/configuration.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ConfigurationAdapter } from '../configuration'; -import { InmemoryConfigurationAdapter } from '../configuration/inmemory_configuration_adapter'; - -import { SourcesAdapter, SourceConfiguration } from './index'; -import { PartialSourceConfigurations } from './types'; - -interface ConfigurationWithSources { - sources?: PartialSourceConfigurations; -} - -export class ConfigurationSourcesAdapter implements SourcesAdapter { - private readonly configuration: ConfigurationAdapter; - - constructor( - configuration: ConfigurationAdapter = new InmemoryConfigurationAdapter( - { sources: {} } - ) - ) { - this.configuration = configuration; - } - - public async getAll() { - const sourceConfigurations = (await this.configuration.get()).sources || { - default: DEFAULT_SOURCE, - }; - const sourceConfigurationsWithDefault = { - ...sourceConfigurations, - default: { - ...DEFAULT_SOURCE, - ...(sourceConfigurations.default || {}), - }, - } as PartialSourceConfigurations; - - return Object.entries(sourceConfigurationsWithDefault).reduce< - Record - >( - (result, [sourceId, sourceConfiguration]) => ({ - ...result, - [sourceId]: { - ...sourceConfiguration, - fields: { - ...DEFAULT_FIELDS, - ...(sourceConfiguration.fields || {}), - }, - }, - }), - {} - ); - } -} - -const DEFAULT_FIELDS = { - container: 'docker.container.name', - host: 'beat.hostname', - message: ['message', '@message'], - pod: 'kubernetes.pod.name', - tiebreaker: '_doc', - timestamp: '@timestamp', -}; - -const DEFAULT_SOURCE = { - fields: DEFAULT_FIELDS, -}; diff --git a/x-pack/plugins/security_solution/server/lib/sources/index.ts b/x-pack/plugins/security_solution/server/lib/sources/index.ts deleted file mode 100644 index 3baf35619ac195..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/sources/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { ConfigurationSourcesAdapter } from './configuration'; - -export class Sources { - constructor(private readonly adapter: SourcesAdapter) {} - - public async getConfiguration(sourceId: string): Promise { - const sourceConfigurations = await this.getAllConfigurations(); - const requestedSourceConfiguration = sourceConfigurations[sourceId]; - if (!requestedSourceConfiguration) { - throw new Error(`Failed to find source '${sourceId}'`); - } - - return requestedSourceConfiguration; - } - - public getAllConfigurations() { - return this.adapter.getAll(); - } -} - -export interface SourcesAdapter { - getAll(): Promise>; -} - -export interface AliasConfiguration { - defaultIndex: string[]; -} - -export interface SourceConfiguration extends AliasConfiguration { - fields: { - container: string; - host: string; - message: string[]; - pod: string; - tiebreaker: string; - timestamp: string; - }; -} diff --git a/x-pack/plugins/security_solution/server/lib/sources/types.ts b/x-pack/plugins/security_solution/server/lib/sources/types.ts deleted file mode 100644 index 424505d789d21d..00000000000000 --- a/x-pack/plugins/security_solution/server/lib/sources/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SourceConfiguration } from './index'; - -export type PartialSourceConfigurations = { - default?: PartialDefaultSourceConfiguration; -} & { - [sourceId: string]: PartialSourceConfiguration; -}; - -export type PartialDefaultSourceConfiguration = { - fields?: Partial; -} & Partial>>; - -export type PartialSourceConfiguration = { - fields?: Partial; -} & Pick>; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/diagnostic_task.ts b/x-pack/plugins/security_solution/server/lib/telemetry/diagnostic_task.ts index c83f37593a0363..f8c3ca914abe1d 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/diagnostic_task.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/diagnostic_task.ts @@ -108,7 +108,9 @@ export class TelemetryDiagTask { } this.logger.debug(`Received ${hits.length} diagnostic alerts`); - const diagAlerts: TelemetryEvent[] = hits.map((h) => h._source); + const diagAlerts: TelemetryEvent[] = hits.flatMap((h) => + h._source != null ? [h._source] : [] + ); this.sender.queueTelemetryEvents(diagAlerts); return diagAlerts.length; }; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.test.ts index 48c996d1e9eff4..7366c94ce1c571 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.test.ts @@ -74,6 +74,33 @@ describe('test', () => { .createTaskRunner; const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); await taskRunner.run(); - expect(mockSender.fetchDiagnosticAlerts).not.toHaveBeenCalled(); + expect(mockSender.fetchEndpointMetrics).not.toHaveBeenCalled(); + expect(mockSender.fetchEndpointPolicyResponses).not.toHaveBeenCalled(); + }); + + test('endpoint task should run when opted in', async () => { + const mockSender = createMockTelemetryEventsSender(true); + const mockTaskManager = taskManagerMock.createSetup(); + const telemetryEpMetaTask = new MockTelemetryEndpointTask(logger, mockTaskManager, mockSender); + + const mockTaskInstance = { + id: TelemetryEndpointTaskConstants.TYPE, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TelemetryEndpointTaskConstants.TYPE, + }; + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][TelemetryEndpointTaskConstants.TYPE] + .createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + await taskRunner.run(); + expect(telemetryEpMetaTask.runTask).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.ts b/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.ts index 71a105d1e58f5c..13b4ebf0b3efb9 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/endpoint_task.ts @@ -166,6 +166,11 @@ export class TelemetryEndpointTask { body: EndpointMetricsAggregation; }; + if (endpointMetricsResponse.aggregations === undefined) { + this.logger.debug(`no endpoint metrics to report`); + return 0; + } + const endpointMetrics = endpointMetricsResponse.aggregations.endpoint_agents.buckets.map( (epMetrics) => { return { @@ -188,8 +193,10 @@ export class TelemetryEndpointTask { */ const agentsResponse = endpointData.fleetAgentsResponse; if (agentsResponse === undefined) { + this.logger.debug('no fleet agent information available'); return 0; } + const fleetAgents = agentsResponse.agents.reduce((cache, agent) => { if (agent.id === DefaultEndpointPolicyIdToIgnore) { return cache; @@ -241,14 +248,18 @@ export class TelemetryEndpointTask { const { body: failedPolicyResponses } = (endpointData.epPolicyResponse as unknown) as { body: EndpointPolicyResponseAggregation; }; - const policyResponses = failedPolicyResponses.aggregations.policy_responses.buckets.reduce( - (cache, endpointAgentId) => { - const doc = endpointAgentId.latest_response.hits.hits[0]; - cache.set(endpointAgentId.key, doc); - return cache; - }, - new Map() - ); + + // If there is no policy responses in the 24h > now then we will continue + const policyResponses = failedPolicyResponses.aggregations + ? failedPolicyResponses.aggregations.policy_responses.buckets.reduce( + (cache, endpointAgentId) => { + const doc = endpointAgentId.latest_response.hits.hits[0]; + cache.set(endpointAgentId.key, doc); + return cache; + }, + new Map() + ) + : new Map(); /** STAGE 4 - Create the telemetry log records * @@ -267,7 +278,7 @@ export class TelemetryEndpointTask { const policyInformation = fleetAgents.get(fleetAgentId); if (policyInformation) { - policyConfig = endpointPolicyCache.get(policyInformation); + policyConfig = endpointPolicyCache.get(policyInformation) || null; if (policyConfig) { failedPolicy = policyResponses.get(policyConfig?.id); @@ -319,7 +330,7 @@ export class TelemetryEndpointTask { ); return telemetryPayloads.length; } catch (err) { - this.logger.error('Could not send endpoint alert telemetry'); + this.logger.warn('could not complete endpoint alert telemetry task'); return 0; } }; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts index f27d22287c9d7c..6e98dcd59e3ecb 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts @@ -22,6 +22,8 @@ export const createMockTelemetryEventsSender = ( start: jest.fn(), stop: jest.fn(), fetchDiagnosticAlerts: jest.fn(), + fetchEndpointMetrics: jest.fn(), + fetchEndpointPolicyResponses: jest.fn(), queueTelemetryEvents: jest.fn(), processEvents: jest.fn(), isTelemetryOptedIn: jest.fn().mockReturnValue(enableTelemtry ?? jest.fn()), diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 6f9279d04b3480..bdd301d9fea1d7 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -7,8 +7,8 @@ import { cloneDeep } from 'lodash'; import axios from 'axios'; +import { SavedObjectsClientContract } from 'kibana/server'; import { SearchRequest } from '@elastic/elasticsearch/api/types'; -import { LegacyAPICaller, SavedObjectsClientContract } from 'kibana/server'; import { URL } from 'url'; import { CoreStart, ElasticsearchClient, Logger } from 'src/core/server'; import { TelemetryPluginStart, TelemetryPluginSetup } from 'src/plugins/telemetry/server'; @@ -48,7 +48,6 @@ export class TelemetryEventsSender { private readonly checkIntervalMs = 60 * 1000; private readonly max_records = 10_000; private readonly logger: Logger; - private core?: CoreStart; private maxQueueSize = 100; private telemetryStart?: TelemetryPluginStart; private telemetrySetup?: TelemetryPluginSetup; @@ -83,7 +82,6 @@ export class TelemetryEventsSender { endpointContextService?: EndpointAppContextService ) { this.telemetryStart = telemetryStart; - this.core = core; this.esClient = core?.elasticsearch.client.asInternalUser; this.agentService = endpointContextService?.getAgentService(); this.agentPolicyService = endpointContextService?.getAgentPolicyService(); @@ -126,18 +124,18 @@ export class TelemetryEventsSender { sort: [ { 'event.ingested': { - order: 'desc', + order: 'desc' as const, }, }, ], }, }; - if (!this.core) { - throw Error('could not fetch diagnostic alerts. core is not available'); + if (this.esClient === undefined) { + throw Error('could not fetch diagnostic alerts. es client is not available'); } - const callCluster = this.core.elasticsearch.legacy.client.callAsInternalUser; - return callCluster('search', query); + + return (await this.esClient.search(query)).body; } public async fetchEndpointMetrics(executeFrom: string, executeTo: string) { @@ -374,11 +372,10 @@ export class TelemetryEventsSender { } private async fetchClusterInfo(): Promise { - if (!this.core) { - throw Error("Couldn't fetch cluster info because core is not available"); + if (this.esClient === undefined) { + throw Error("Couldn't fetch cluster info. es client is not available"); } - const callCluster = this.core.elasticsearch.legacy.client.callAsInternalUser; - return getClusterInfo(callCluster); + return getClusterInfo(this.esClient); } private async fetchTelemetryUrl(channel: string): Promise { @@ -390,12 +387,11 @@ export class TelemetryEventsSender { } private async fetchLicenseInfo(): Promise { - if (!this.core) { + if (!this.esClient) { return undefined; } try { - const callCluster = this.core.elasticsearch.legacy.client.callAsInternalUser; - const ret = await getLicense(callCluster, true); + const ret = await getLicense(this.esClient, true); return ret.license; } catch (err) { this.logger.warn(`Error retrieving license: ${err}`); @@ -615,13 +611,15 @@ export interface ESClusterInfo { /** * Get the cluster info from the connected cluster. - * + * Copied from: + * src/plugins/telemetry/server/telemetry_collection/get_cluster_info.ts * This is the equivalent to GET / * - * @param {function} callCluster The callWithInternalUser handler (exposed for testing) + * @param {function} esClient The asInternalUser handler (exposed for testing) */ -function getClusterInfo(callCluster: LegacyAPICaller) { - return callCluster('info'); +export async function getClusterInfo(esClient: ElasticsearchClient) { + const { body } = await esClient.info(); + return body; } // From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html @@ -639,14 +637,19 @@ export interface ESLicense { start_date_in_millis?: number; } -function getLicense(callCluster: LegacyAPICaller, local: boolean) { - return callCluster<{ license: ESLicense }>('transport.request', { - method: 'GET', - path: '/_license', - query: { - local, - // For versions >= 7.6 and < 8.0, this flag is needed otherwise 'platinum' is returned for 'enterprise' license. - accept_enterprise: 'true', - }, - }); +async function getLicense( + esClient: ElasticsearchClient, + local: boolean +): Promise<{ license: ESLicense }> { + return ( + await esClient.transport.request({ + method: 'GET', + path: '/_license', + querystring: { + local, + // For versions >= 7.6 and < 8.0, this flag is needed otherwise 'platinum' is returned for 'enterprise' license. + accept_enterprise: 'true', + }, + }) + ).body as Promise<{ license: ESLicense }>; // Note: We have to as cast since transport.request doesn't have generics } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/helpers.test.ts index 2b3eda31916a80..bee39be6cbd5c2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines/helpers.test.ts @@ -18,7 +18,6 @@ import { } from '../../../../detection_engine/routes/__mocks__'; import { addPrepackagedRulesRequest, - getNonEmptyIndex, getFindResultWithSingleHit, } from '../../../../detection_engine/routes/__mocks__/request_responses'; @@ -47,7 +46,6 @@ describe('installPrepackagedTimelines', () => { authz: {}, } as unknown) as SecurityPluginSetup; - clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); jest.doMock('./helpers', () => { diff --git a/x-pack/plugins/security_solution/server/lib/types.ts b/x-pack/plugins/security_solution/server/lib/types.ts index 6ef51bc3c53d48..31211869d054d0 100644 --- a/x-pack/plugins/security_solution/server/lib/types.ts +++ b/x-pack/plugins/security_solution/server/lib/types.ts @@ -5,38 +5,8 @@ * 2.0. */ -import { AuthenticatedUser } from '../../../security/common/model'; export { ConfigType as Configuration } from '../config'; -import type { SecuritySolutionRequestHandlerContext } from '../types'; - -import { FrameworkAdapter, FrameworkRequest } from './framework'; -import { IndexFields } from './index_fields'; -import { SourceStatus } from './source_status'; -import { Sources } from './sources'; -import { Notes } from './timeline/saved_object/notes'; -import { PinnedEvent } from './timeline/saved_object/pinned_events'; -import { Timeline } from './timeline/saved_object/timelines'; import { TotalValue, BaseHit, Explanation } from '../../common/detection_engine/types'; -import { SignalHit } from './detection_engine/signals/types'; - -export interface AppDomainLibs { - fields: IndexFields; -} - -export interface AppBackendLibs extends AppDomainLibs { - framework: FrameworkAdapter; - sources: Sources; - sourceStatus: SourceStatus; - timeline: Timeline; - note: Notes; - pinnedEvent: PinnedEvent; -} - -export interface SiemContext { - req: FrameworkRequest; - context: SecuritySolutionRequestHandlerContext; - user: AuthenticatedUser | null; -} export interface ShardsResponse { total: number; @@ -101,8 +71,6 @@ export interface SearchResponse extends BaseSearchResponse { export type SearchHit = SearchResponse['hits']['hits'][0]; -export type SearchSignalHit = SearchResponse['hits']['hits'][0]; - export interface TermAggregationBucket { key: string; doc_count: number; @@ -115,35 +83,3 @@ export interface TermAggregationBucket { value: number; }; } - -export interface TermAggregation { - [agg: string]: { - buckets: TermAggregationBucket[]; - }; -} - -export interface TotalHit { - value: number; - relation: string; -} - -export interface Hit { - _index: string; - _type: string; - _id: string; - _score: number | null; -} - -export interface Hits { - hits: { - total: T; - max_score: number | null; - hits: U[]; - }; -} - -export interface MSearchHeader { - index: string[] | string; - allowNoIndices?: boolean; - ignoreUnavailable?: boolean; -} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 07b0e2ed4b9dd8..9d2e918d4f2745 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -46,7 +46,6 @@ import { SpacesPluginSetup as SpacesSetup } from '../../spaces/server'; import { ILicense, LicensingPluginStart } from '../../licensing/server'; import { FleetStartContract } from '../../fleet/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import { compose } from './lib/compose/kibana'; import { createQueryAlertType } from './lib/detection_engine/reference_rules/query'; import { createEqlAlertType } from './lib/detection_engine/reference_rules/eql'; import { createThresholdAlertType } from './lib/detection_engine/reference_rules/threshold'; @@ -317,8 +316,6 @@ export class Plugin implements IPlugin { const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider( depsStart.data, diff --git a/x-pack/plugins/security_solution/server/utils/build_query/calculate_timeseries_interval.ts b/x-pack/plugins/security_solution/server/utils/build_query/calculate_timeseries_interval.ts index c3be85a5e3a3c4..071d1d6b4db2fb 100644 --- a/x-pack/plugins/security_solution/server/utils/build_query/calculate_timeseries_interval.ts +++ b/x-pack/plugins/security_solution/server/utils/build_query/calculate_timeseries_interval.ts @@ -10,86 +10,6 @@ ** x-pack/plugins/apm/server/lib/helpers/get_bucket_size/calculate_auto.js */ import moment from 'moment'; -import { get } from 'lodash/fp'; -const d = moment.duration; - -const roundingRules = [ - [d(500, 'ms'), d(100, 'ms')], - [d(5, 'second'), d(1, 'second')], - [d(7.5, 'second'), d(5, 'second')], - [d(15, 'second'), d(10, 'second')], - [d(45, 'second'), d(30, 'second')], - [d(3, 'minute'), d(1, 'minute')], - [d(9, 'minute'), d(5, 'minute')], - [d(20, 'minute'), d(10, 'minute')], - [d(45, 'minute'), d(30, 'minute')], - [d(2, 'hour'), d(1, 'hour')], - [d(6, 'hour'), d(3, 'hour')], - [d(24, 'hour'), d(12, 'hour')], - [d(1, 'week'), d(1, 'd')], - [d(3, 'week'), d(1, 'week')], - [d(1, 'year'), d(1, 'month')], - [Infinity, d(1, 'year')], -]; - -const revRoundingRules = roundingRules.slice(0).reverse(); - -const find = ( - rules: Array>, - check: ( - bound: number | moment.Duration, - interval: number | moment.Duration, - target: number - ) => number | moment.Duration | undefined, - last?: boolean -): ((buckets: number, duration: number | moment.Duration) => moment.Duration | undefined) => { - const pick = (buckets: number, duration: number | moment.Duration): number | moment.Duration => { - const target = - typeof duration === 'number' ? duration / buckets : duration.asMilliseconds() / buckets; - let lastResp = null; - - for (let i = 0; i < rules.length; i++) { - const rule = rules[i]; - const resp = check(rule[0], rule[1], target); - - if (resp == null) { - if (last) { - if (lastResp) return lastResp; - break; - } - } - - if (!last && resp) return resp; - lastResp = resp; - } - - // fallback to just a number of milliseconds, ensure ms is >= 1 - const ms = Math.max(Math.floor(target), 1); - return moment.duration(ms, 'ms'); - }; - - return (buckets, duration) => { - const interval = pick(buckets, duration); - const intervalData = get('_data', interval); - if (intervalData) return moment.duration(intervalData); - }; -}; - -export const calculateAuto = { - near: find( - revRoundingRules, - (bound, interval, target) => { - if (bound > target) return interval; - }, - true - ), - lessThan: find(revRoundingRules, (_bound, interval, target) => { - if (interval < target) return interval; - }), - atLeast: find(revRoundingRules, (_bound, interval, target) => { - if (interval <= target) return interval; - }), -}; export const calculateTimeSeriesInterval = (from: string, to: string) => { return `${Math.floor(moment(to).diff(moment(from)) / 32)}ms`; diff --git a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts index b0b70aeb3ea340..42f1b467ed4c2d 100644 --- a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts @@ -7,19 +7,9 @@ import { Transform } from 'stream'; import { has, isString } from 'lodash/fp'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import * as t from 'io-ts'; import { createMapStream, createFilterStream } from '@kbn/utils'; -import { exactCheck, formatErrors } from '@kbn/securitysolution-io-ts-utils'; -import { BadRequestError } from '@kbn/securitysolution-es-utils'; -import { importRuleValidateTypeDependents } from '../../../common/detection_engine/schemas/request/import_rules_type_dependents'; -import { - ImportRulesSchemaDecoded, - importRulesSchema, - ImportRulesSchema, -} from '../../../common/detection_engine/schemas/request/import_rules_schema'; +import { ImportRulesSchemaDecoded } from '../../../common/detection_engine/schemas/request/import_rules_schema'; export interface RulesObjectsExportResultDetails { /** number of successfully exported objects */ @@ -44,29 +34,6 @@ export const filterExportedCounts = (): Transform => { ); }; -export const validateRules = (): Transform => { - return createMapStream((obj: ImportRulesSchema) => { - if (!(obj instanceof Error)) { - const decoded = importRulesSchema.decode(obj); - const checked = exactCheck(obj, decoded); - const onLeft = (errors: t.Errors): BadRequestError | ImportRulesSchemaDecoded => { - return new BadRequestError(formatErrors(errors).join()); - }; - const onRight = (schema: ImportRulesSchema): BadRequestError | ImportRulesSchemaDecoded => { - const validationErrors = importRuleValidateTypeDependents(schema); - if (validationErrors.length) { - return new BadRequestError(validationErrors.join()); - } else { - return schema as ImportRulesSchemaDecoded; - } - }; - return pipe(checked, fold(onLeft, onRight)); - } else { - return obj; - } - }); -}; - // Adaptation from: saved_objects/import/create_limit_stream.ts export const createLimitStream = (limit: number): Transform => { let counter = 0; diff --git a/x-pack/plugins/security_solution/server/utils/runtime_types.ts b/x-pack/plugins/security_solution/server/utils/runtime_types.ts index 5d1971a4223e38..50045568357a07 100644 --- a/x-pack/plugins/security_solution/server/utils/runtime_types.ts +++ b/x-pack/plugins/security_solution/server/utils/runtime_types.ts @@ -5,15 +5,10 @@ * 2.0. */ -import { either, fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; +import { either } from 'fp-ts/lib/Either'; import * as rt from 'io-ts'; -import { failure } from 'io-ts/lib/PathReporter'; import get from 'lodash/get'; -type ErrorFactory = (message: string) => Error; - export type GenericIntersectionC = // eslint-disable-next-line @typescript-eslint/no-explicit-any | rt.IntersectionC<[any, any]> @@ -24,18 +19,6 @@ export type GenericIntersectionC = // eslint-disable-next-line @typescript-eslint/no-explicit-any | rt.IntersectionC<[any, any, any, any, any]>; -export const createPlainError = (message: string) => new Error(message); - -export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { - throw createError(failure(errors).join('\n')); -}; - -export const decodeOrThrow = ( - runtimeType: rt.Type, - createError: ErrorFactory = createPlainError -) => (inputValue: I) => - pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); - const getProps = ( codec: | rt.HasProps diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts index 9e5b542e882d18..5e2e4699c25ca0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -27,7 +27,14 @@ export const ConditionMetAlertInstanceId = 'query matched'; export function getAlertType( logger: Logger -): AlertType { +): AlertType< + EsQueryAlertParams, + never, // Only use if defining useSavedObjectReferences hook + EsQueryAlertState, + {}, + ActionContext, + typeof ActionGroupId +> { const alertTypeName = i18n.translate('xpack.stackAlerts.esQuery.alertTypeTitle', { defaultMessage: 'Elasticsearch query', }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts index 43ae726fa2478a..111fda3bdaca83 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -140,6 +140,7 @@ export interface GeoContainmentInstanceContext extends AlertInstanceContext { export type GeoContainmentAlertType = AlertType< GeoContainmentParams, + never, // Only use if defining useSavedObjectReferences hook GeoContainmentState, GeoContainmentInstanceState, GeoContainmentInstanceContext, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts index ee0b36bcd17668..023ea168a77d2a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/index.ts @@ -26,6 +26,7 @@ export function register(params: RegisterParams) { const { logger, alerting } = params; alerting.registerType< GeoContainmentParams, + never, // Only use if defining useSavedObjectReferences hook GeoContainmentState, GeoContainmentInstanceState, GeoContainmentInstanceContext, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 035d999699d4b8..46c3c3467cefea 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -23,7 +23,7 @@ export const ActionGroupId = 'threshold met'; export function getAlertType( logger: Logger, data: Promise -): AlertType { +): AlertType { const alertTypeName = i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeTitle', { defaultMessage: 'Index threshold', }); diff --git a/x-pack/plugins/stack_alerts/server/types.ts b/x-pack/plugins/stack_alerts/server/types.ts index 9725d901383002..b78aa4e6432d5e 100644 --- a/x-pack/plugins/stack_alerts/server/types.ts +++ b/x-pack/plugins/stack_alerts/server/types.ts @@ -11,6 +11,7 @@ import { PluginSetupContract as AlertingSetup } from '../../alerting/server'; export { PluginSetupContract as AlertingSetup, AlertType, + RuleParamsAndRefs, AlertExecutorOptions, } from '../../alerting/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; diff --git a/x-pack/plugins/task_manager/kibana.json b/x-pack/plugins/task_manager/kibana.json index ad2d5d00ae0be5..aab1cd0ab41a5e 100644 --- a/x-pack/plugins/task_manager/kibana.json +++ b/x-pack/plugins/task_manager/kibana.json @@ -4,5 +4,6 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "task_manager"], + "optionalPlugins": ["usageCollection"], "ui": false } diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 5e44181f35b202..14d95e3fd22264 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -13,6 +13,10 @@ describe('config validation', () => { expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { "enabled": true, + "ephemeral_tasks": Object { + "enabled": false, + "request_capacity": 10, + }, "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, @@ -65,6 +69,10 @@ describe('config validation', () => { expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { "enabled": true, + "ephemeral_tasks": Object { + "enabled": false, + "request_capacity": 10, + }, "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, @@ -104,6 +112,10 @@ describe('config validation', () => { expect(configSchema.validate(config)).toMatchInlineSnapshot(` Object { "enabled": true, + "ephemeral_tasks": Object { + "enabled": false, + "request_capacity": 10, + }, "index": ".kibana_task_manager", "max_attempts": 3, "max_poll_inactivity_cycles": 10, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index 03bb98170a34a5..9b4f4856bf8a99 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -12,6 +12,7 @@ export const DEFAULT_MAX_WORKERS = 10; export const DEFAULT_POLL_INTERVAL = 3000; export const DEFAULT_MAX_POLL_INACTIVITY_CYCLES = 10; export const DEFAULT_VERSION_CONFLICT_THRESHOLD = 80; +export const DEFAULT_MAX_EPHEMERAL_REQUEST_CAPACITY = MAX_WORKERS_LIMIT; // Monitoring Constants // =================== @@ -117,6 +118,16 @@ export const configSchema = schema.object( defaultValue: DEFAULT_MONITORING_STATS_WARN_DELAYED_TASK_START_IN_SECONDS, }), }), + ephemeral_tasks: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + /* How many requests can Task Manager buffer before it rejects new requests. */ + request_capacity: schema.number({ + // a nice round contrived number, feel free to change as we learn how it behaves + defaultValue: 10, + min: 1, + max: DEFAULT_MAX_EPHEMERAL_REQUEST_CAPACITY, + }), + }), }, { validate: (config) => { diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.mock.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.mock.ts new file mode 100644 index 00000000000000..c1ae0c4141bf10 --- /dev/null +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle'; +import { TaskLifecycleEvent } from './polling_lifecycle'; +import { of, Observable } from 'rxjs'; + +export const ephemeralTaskLifecycleMock = { + create(opts: { events$?: Observable; getQueuedTasks?: () => number }) { + return ({ + attemptToRun: jest.fn(), + get events() { + return opts.events$ ?? of(); + }, + get queuedTasks() { + return opts.getQueuedTasks ? opts.getQueuedTasks() : 0; + }, + } as unknown) as jest.Mocked; + }, +}; diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts new file mode 100644 index 00000000000000..182e7cd5bcabfe --- /dev/null +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.test.ts @@ -0,0 +1,396 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import _ from 'lodash'; +import { Subject } from 'rxjs'; + +import { TaskLifecycleEvent } from './polling_lifecycle'; +import { createInitialMiddleware } from './lib/middleware'; +import { TaskTypeDictionary } from './task_type_dictionary'; +import { mockLogger } from './test_utils'; +import { asErr, asOk } from './lib/result_type'; +import { FillPoolResult } from './lib/fill_pool'; +import { EphemeralTaskLifecycle, EphemeralTaskLifecycleOpts } from './ephemeral_task_lifecycle'; +import { ConcreteTaskInstance, TaskStatus } from './task'; +import uuid from 'uuid'; +import { asTaskPollingCycleEvent, asTaskRunEvent, TaskPersistence } from './task_events'; +import { TaskRunResult } from './task_running'; +import { TaskPoolRunResult } from './task_pool'; +import { TaskPoolMock } from './task_pool.mock'; + +describe('EphemeralTaskLifecycle', () => { + function initTaskLifecycleParams({ + config, + ...optOverrides + }: { + config?: Partial; + } & Partial> = {}) { + const taskManagerLogger = mockLogger(); + const poolCapacity = jest.fn(); + const pool = TaskPoolMock.create(poolCapacity); + const lifecycleEvent$ = new Subject(); + const elasticsearchAndSOAvailability$ = new Subject(); + const opts: EphemeralTaskLifecycleOpts = { + logger: taskManagerLogger, + definitions: new TaskTypeDictionary(taskManagerLogger), + config: { + enabled: true, + max_workers: 10, + index: 'foo', + max_attempts: 9, + poll_interval: 6000000, + version_conflict_threshold: 80, + max_poll_inactivity_cycles: 10, + request_capacity: 1000, + monitored_aggregated_stats_refresh_rate: 5000, + monitored_stats_required_freshness: 5000, + monitored_stats_running_average_window: 50, + monitored_stats_health_verbose_log: { + enabled: true, + warn_delayed_task_start_in_seconds: 60, + }, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, + }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, + ...config, + }, + elasticsearchAndSOAvailability$, + pool, + lifecycleEvent: lifecycleEvent$, + middleware: createInitialMiddleware(), + ...optOverrides, + }; + + opts.definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + }); + + pool.run.mockResolvedValue(Promise.resolve(TaskPoolRunResult.RunningAllClaimedTasks)); + + return { poolCapacity, lifecycleEvent$, pool, elasticsearchAndSOAvailability$, opts }; + } + + describe('constructor', () => { + test('avoids unnecesery subscription if ephemeral tasks are disabled', () => { + const { opts } = initTaskLifecycleParams({ + config: { + ephemeral_tasks: { + enabled: false, + request_capacity: 10, + }, + }, + }); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const task = mockTask(); + expect(ephemeralTaskLifecycle.attemptToRun(task)).toMatchObject(asErr(task)); + }); + + test('queues up tasks when ephemeral tasks are enabled', () => { + const { opts } = initTaskLifecycleParams(); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const task = mockTask(); + expect(ephemeralTaskLifecycle.attemptToRun(task)).toMatchObject(asOk(task)); + }); + + test('rejects tasks when ephemeral tasks are enabled and queue is full', () => { + const { opts } = initTaskLifecycleParams({ + config: { ephemeral_tasks: { enabled: true, request_capacity: 2 } }, + }); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const task = mockTask(); + expect(ephemeralTaskLifecycle.attemptToRun(task)).toMatchObject(asOk(task)); + const task2 = mockTask(); + expect(ephemeralTaskLifecycle.attemptToRun(task2)).toMatchObject(asOk(task2)); + + const rejectedTask = mockTask(); + expect(ephemeralTaskLifecycle.attemptToRun(rejectedTask)).toMatchObject(asErr(rejectedTask)); + }); + + test('pulls tasks off queue when a polling cycle completes', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const task = mockTask({ id: `my-phemeral-task` }); + expect(ephemeralTaskLifecycle.attemptToRun(task)).toMatchObject(asOk(task)); + + poolCapacity.mockReturnValue({ + availableWorkers: 10, + }); + + lifecycleEvent$.next( + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed })) + ); + + expect(pool.run).toHaveBeenCalledTimes(1); + + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(1); + expect(`${taskRunners[0]}`).toMatchInlineSnapshot(`"foo \\"my-phemeral-task\\" (Ephemeral)"`); + }); + + test('pulls tasks off queue when a task run completes', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const task = mockTask({ id: `my-phemeral-task` }); + expect(ephemeralTaskLifecycle.attemptToRun(task)).toMatchObject(asOk(task)); + + poolCapacity.mockReturnValue({ + availableWorkers: 10, + }); + + lifecycleEvent$.next( + asTaskRunEvent( + uuid.v4(), + asOk({ + task: mockTask(), + result: TaskRunResult.Success, + persistence: TaskPersistence.Ephemeral, + }) + ) + ); + + expect(pool.run).toHaveBeenCalledTimes(1); + + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(1); + expect(`${taskRunners[0]}`).toMatchInlineSnapshot(`"foo \\"my-phemeral-task\\" (Ephemeral)"`); + }); + + test('pulls as many tasks off queue as it has capacity for', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const tasks = [mockTask(), mockTask(), mockTask()]; + expect(ephemeralTaskLifecycle.attemptToRun(tasks[0])).toMatchObject(asOk(tasks[0])); + expect(ephemeralTaskLifecycle.attemptToRun(tasks[1])).toMatchObject(asOk(tasks[1])); + expect(ephemeralTaskLifecycle.attemptToRun(tasks[2])).toMatchObject(asOk(tasks[2])); + + poolCapacity.mockReturnValue({ + availableWorkers: 2, + }); + + lifecycleEvent$.next( + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed })) + ); + + expect(pool.run).toHaveBeenCalledTimes(1); + + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(2); + expect(`${taskRunners[0]}`).toEqual(`foo "${tasks[0].id}" (Ephemeral)`); + expect(`${taskRunners[1]}`).toEqual(`foo "${tasks[1].id}" (Ephemeral)`); + }); + + test('pulls only as many tasks of the same type as is allowed by maxConcurrency', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + opts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const firstLimitedTask = mockTask({ taskType: 'report' }); + const secondLimitedTask = mockTask({ taskType: 'report' }); + // both are queued + expect(ephemeralTaskLifecycle.attemptToRun(firstLimitedTask)).toMatchObject( + asOk(firstLimitedTask) + ); + expect(ephemeralTaskLifecycle.attemptToRun(secondLimitedTask)).toMatchObject( + asOk(secondLimitedTask) + ); + + // pool has capacity for both + poolCapacity.mockReturnValue({ + availableWorkers: 10, + }); + pool.getOccupiedWorkersByType.mockReturnValue(0); + + lifecycleEvent$.next( + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed })) + ); + + expect(pool.run).toHaveBeenCalledTimes(1); + + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(1); + expect(`${taskRunners[0]}`).toEqual(`report "${firstLimitedTask.id}" (Ephemeral)`); + }); + + test('when pulling tasks from the queue, it takes into account the maxConcurrency of tasks that are already in the pool', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + opts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const firstLimitedTask = mockTask({ taskType: 'report' }); + const secondLimitedTask = mockTask({ taskType: 'report' }); + // both are queued + expect(ephemeralTaskLifecycle.attemptToRun(firstLimitedTask)).toMatchObject( + asOk(firstLimitedTask) + ); + expect(ephemeralTaskLifecycle.attemptToRun(secondLimitedTask)).toMatchObject( + asOk(secondLimitedTask) + ); + + // pool has capacity in general + poolCapacity.mockReturnValue({ + availableWorkers: 2, + }); + // but when we ask how many it has occupied by type - wee always have one worker already occupied by that type + pool.getOccupiedWorkersByType.mockReturnValue(1); + + lifecycleEvent$.next( + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed })) + ); + + expect(pool.run).toHaveBeenCalledTimes(0); + + // now we release the worker in the pool and cause another cycle in the epheemral queue + pool.getOccupiedWorkersByType.mockReturnValue(0); + lifecycleEvent$.next( + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed })) + ); + + expect(pool.run).toHaveBeenCalledTimes(1); + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(1); + expect(`${taskRunners[0]}`).toEqual(`report "${firstLimitedTask.id}" (Ephemeral)`); + }); + }); + + test('pulls tasks with both maxConcurrency and unlimited concurrency', () => { + const { pool, poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + opts.definitions.registerTaskDefinitions({ + report: { + title: 'report', + maxConcurrency: 1, + createTaskRunner: jest.fn(), + }, + }); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const fooTasks = [mockTask(), mockTask(), mockTask()]; + expect(ephemeralTaskLifecycle.attemptToRun(fooTasks[0])).toMatchObject(asOk(fooTasks[0])); + + const firstLimitedTask = mockTask({ taskType: 'report' }); + expect(ephemeralTaskLifecycle.attemptToRun(firstLimitedTask)).toMatchObject( + asOk(firstLimitedTask) + ); + + expect(ephemeralTaskLifecycle.attemptToRun(fooTasks[1])).toMatchObject(asOk(fooTasks[1])); + + const secondLimitedTask = mockTask({ taskType: 'report' }); + expect(ephemeralTaskLifecycle.attemptToRun(secondLimitedTask)).toMatchObject( + asOk(secondLimitedTask) + ); + + expect(ephemeralTaskLifecycle.attemptToRun(fooTasks[2])).toMatchObject(asOk(fooTasks[2])); + + // pool has capacity for all + poolCapacity.mockReturnValue({ + availableWorkers: 10, + }); + pool.getOccupiedWorkersByType.mockReturnValue(0); + + lifecycleEvent$.next(asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed }))); + + expect(pool.run).toHaveBeenCalledTimes(1); + + const taskRunners = pool.run.mock.calls[0][0]; + expect(taskRunners).toHaveLength(4); + const asStrings = taskRunners.map((taskRunner) => `${taskRunner}`); + expect(asStrings).toContain(`foo "${fooTasks[0].id}" (Ephemeral)`); + expect(asStrings).toContain(`report "${firstLimitedTask.id}" (Ephemeral)`); + expect(asStrings).toContain(`foo "${fooTasks[1].id}" (Ephemeral)`); + expect(asStrings).toContain(`foo "${fooTasks[2].id}" (Ephemeral)`); + }); + + test('properly removes from the queue after pulled', () => { + const { poolCapacity, opts, lifecycleEvent$ } = initTaskLifecycleParams(); + + const ephemeralTaskLifecycle = new EphemeralTaskLifecycle(opts); + + const tasks = [mockTask(), mockTask(), mockTask()]; + expect(ephemeralTaskLifecycle.attemptToRun(tasks[0])).toMatchObject(asOk(tasks[0])); + expect(ephemeralTaskLifecycle.attemptToRun(tasks[1])).toMatchObject(asOk(tasks[1])); + expect(ephemeralTaskLifecycle.attemptToRun(tasks[2])).toMatchObject(asOk(tasks[2])); + + expect(ephemeralTaskLifecycle.queuedTasks).toBe(3); + poolCapacity.mockReturnValue({ + availableWorkers: 1, + }); + lifecycleEvent$.next(asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed }))); + expect(ephemeralTaskLifecycle.queuedTasks).toBe(2); + + poolCapacity.mockReturnValue({ + availableWorkers: 1, + }); + lifecycleEvent$.next(asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed }))); + expect(ephemeralTaskLifecycle.queuedTasks).toBe(1); + + poolCapacity.mockReturnValue({ + availableWorkers: 1, + }); + lifecycleEvent$.next(asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed }))); + expect(ephemeralTaskLifecycle.queuedTasks).toBe(0); + }); +}); + +function mockTask(overrides: Partial = {}): ConcreteTaskInstance { + return { + id: uuid.v4(), + runAt: new Date(), + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: TaskStatus.Idle, + params: { hello: 'world' }, + state: { baby: 'Henhen' }, + user: 'jimbo', + scope: ['reporting'], + ownerId: '', + startedAt: null, + retryAt: null, + scheduledAt: new Date(), + ...overrides, + }; +} diff --git a/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.ts b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.ts new file mode 100644 index 00000000000000..ce719ebed36e44 --- /dev/null +++ b/x-pack/plugins/task_manager/server/ephemeral_task_lifecycle.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Subject, Observable, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { Logger } from '../../../../src/core/server'; + +import { Result, asErr, asOk } from './lib/result_type'; +import { TaskManagerConfig } from './config'; + +import { asTaskManagerStatEvent, isTaskRunEvent, isTaskPollingCycleEvent } from './task_events'; +import { Middleware } from './lib/middleware'; +import { EphemeralTaskInstance } from './task'; +import { TaskTypeDictionary } from './task_type_dictionary'; +import { TaskLifecycleEvent } from './polling_lifecycle'; +import { EphemeralTaskManagerRunner } from './task_running/ephemeral_task_runner'; +import { TaskPool } from './task_pool'; + +export interface EphemeralTaskLifecycleOpts { + logger: Logger; + definitions: TaskTypeDictionary; + config: TaskManagerConfig; + middleware: Middleware; + elasticsearchAndSOAvailability$: Observable; + pool: TaskPool; + lifecycleEvent: Observable; +} + +export type EphemeralTaskInstanceRequest = Omit; + +export class EphemeralTaskLifecycle { + private definitions: TaskTypeDictionary; + private pool: TaskPool; + private lifecycleEvent: Observable; + // all task related events (task claimed, task marked as running, etc.) are emitted through events$ + private events$ = new Subject(); + private ephemeralTaskQueue: Array<{ + task: EphemeralTaskInstanceRequest; + enqueuedAt: number; + }> = []; + private logger: Logger; + private config: TaskManagerConfig; + private middleware: Middleware; + private lifecycleSubscription: Subscription = Subscription.EMPTY; + + constructor({ + logger, + middleware, + definitions, + pool, + lifecycleEvent, + config, + }: EphemeralTaskLifecycleOpts) { + this.logger = logger; + this.middleware = middleware; + this.definitions = definitions; + this.pool = pool; + this.lifecycleEvent = lifecycleEvent; + this.config = config; + + if (this.enabled) { + this.lifecycleSubscription = this.lifecycleEvent + .pipe( + filter((e) => { + const hasPollingCycleCompleted = isTaskPollingCycleEvent(e); + if (hasPollingCycleCompleted) { + this.emitEvent( + asTaskManagerStatEvent('queuedEphemeralTasks', asOk(this.queuedTasks)) + ); + } + return ( + // when a polling cycle or a task run have just completed + (hasPollingCycleCompleted || isTaskRunEvent(e)) && + // we want to know when the queue has ephemeral task run requests + this.queuedTasks > 0 && + this.getCapacity() > 0 + ); + }) + ) + .subscribe(async (e) => { + let overallCapacity = this.getCapacity(); + const capacityByType = new Map(); + const tasksWithinCapacity = [...this.ephemeralTaskQueue] + .filter(({ task }) => { + if (overallCapacity > 0) { + if (!capacityByType.has(task.taskType)) { + capacityByType.set(task.taskType, this.getCapacity(task.taskType)); + } + if (capacityByType.get(task.taskType)! > 0) { + overallCapacity--; + capacityByType.set(task.taskType, capacityByType.get(task.taskType)! - 1); + return true; + } + } + }) + .map((ephemeralTask) => { + const index = this.ephemeralTaskQueue.indexOf(ephemeralTask); + if (index >= 0) { + this.ephemeralTaskQueue.splice(index, 1); + } + this.emitEvent( + asTaskManagerStatEvent( + 'ephemeralTaskDelay', + asOk(Date.now() - ephemeralTask.enqueuedAt) + ) + ); + return this.createTaskRunnerForTask(ephemeralTask.task); + }); + + if (tasksWithinCapacity.length) { + this.pool + .run(tasksWithinCapacity) + .then((successTaskPoolRunResult) => { + this.logger.debug( + `Successful ephemeral task lifecycle resulted in: ${successTaskPoolRunResult}` + ); + }) + .catch((error) => { + this.logger.debug(`Failed ephemeral task lifecycle resulted in: ${error}`); + }); + } + }); + } + } + + public get enabled(): boolean { + return this.config.ephemeral_tasks.enabled; + } + + public get events(): Observable { + return this.events$; + } + + private getCapacity = (taskType?: string) => + taskType && this.definitions.get(taskType)?.maxConcurrency + ? Math.max( + Math.min( + this.pool.availableWorkers, + this.definitions.get(taskType)!.maxConcurrency! - + this.pool.getOccupiedWorkersByType(taskType) + ), + 0 + ) + : this.pool.availableWorkers; + + private emitEvent = (event: TaskLifecycleEvent) => { + this.events$.next(event); + }; + + public attemptToRun(task: EphemeralTaskInstanceRequest) { + if (this.lifecycleSubscription.closed) { + return asErr(task); + } + return pushIntoSetWithTimestamp( + this.ephemeralTaskQueue, + this.config.ephemeral_tasks.request_capacity, + task + ); + } + + public get queuedTasks() { + return this.ephemeralTaskQueue.length; + } + + private createTaskRunnerForTask = ( + instance: EphemeralTaskInstanceRequest + ): EphemeralTaskManagerRunner => { + return new EphemeralTaskManagerRunner({ + logger: this.logger, + instance: { + ...instance, + startedAt: new Date(), + }, + definitions: this.definitions, + beforeRun: this.middleware.beforeRun, + beforeMarkRunning: this.middleware.beforeMarkRunning, + onTaskEvent: this.emitEvent, + }); + }; +} + +/** + * Pushes values into a bounded set + * @param set A Set of generic type T + * @param maxCapacity How many values are we allowed to push into the set + * @param value A value T to push into the set if it is there + */ +function pushIntoSetWithTimestamp( + set: Array<{ + task: EphemeralTaskInstanceRequest; + enqueuedAt: number; + }>, + maxCapacity: number, + task: EphemeralTaskInstanceRequest +): Result { + if (set.length >= maxCapacity) { + return asErr(task); + } + set.push({ task, enqueuedAt: Date.now() }); + return asOk(task); +} diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts index 80f0e298a8ac38..0a0630d82f32da 100644 --- a/x-pack/plugins/task_manager/server/index.ts +++ b/x-pack/plugins/task_manager/server/index.ts @@ -15,13 +15,19 @@ export const plugin = (initContext: PluginInitializerContext) => new TaskManager export { TaskInstance, ConcreteTaskInstance, + EphemeralTask, TaskRunCreatorFunction, TaskStatus, RunContext, } from './task'; export { asInterval } from './lib/intervals'; -export { isUnrecoverableError, throwUnrecoverableError } from './task_running'; +export { + isUnrecoverableError, + throwUnrecoverableError, + isEphemeralTaskRejectedDueToCapacityError, +} from './task_running'; +export { RunNowResult } from './task_scheduling'; export { TaskManagerPlugin as TaskManager, diff --git a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts index f925c4d978ad7d..496c0138cb1e5f 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts +++ b/x-pack/plugins/task_manager/server/integration_tests/managed_configuration.test.ts @@ -51,11 +51,17 @@ describe('managed configuration', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, }); logger = context.logger.get('taskManager'); const taskManager = new TaskManagerPlugin(context); - (await taskManager.setup(coreMock.createSetup())).registerTaskDefinitions({ + ( + await taskManager.setup(coreMock.createSetup(), { usageCollection: undefined }) + ).registerTaskDefinitions({ foo: { title: 'Foo', createTaskRunner: jest.fn(), diff --git a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts index aca73a4b774341..b8e3e78925df59 100644 --- a/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts +++ b/x-pack/plugins/task_manager/server/lib/log_health_metrics.test.ts @@ -8,10 +8,10 @@ import { merge } from 'lodash'; import { loggingSystemMock } from 'src/core/server/mocks'; import { configSchema, TaskManagerConfig } from '../config'; import { HealthStatus } from '../monitoring'; -import { TaskPersistence } from '../monitoring/task_run_statistics'; import { MonitoredHealth } from '../routes/health'; import { logHealthMetrics, resetLastLogLevel } from './log_health_metrics'; import { Logger } from '../../../../../src/core/server'; +import { TaskPersistence } from '../task_events'; jest.mock('./calculate_health_status', () => ({ calculateHealthStatus: jest.fn(), diff --git a/x-pack/plugins/task_manager/server/mocks.ts b/x-pack/plugins/task_manager/server/mocks.ts index c713e1e98a1e37..2db8cdd6268c79 100644 --- a/x-pack/plugins/task_manager/server/mocks.ts +++ b/x-pack/plugins/task_manager/server/mocks.ts @@ -23,8 +23,10 @@ const createStartMock = () => { remove: jest.fn(), schedule: jest.fn(), runNow: jest.fn(), + ephemeralRunNow: jest.fn(), ensureScheduled: jest.fn(), removeIfExists: jest.fn(), + supportsEphemeralTasks: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts index bd8ecf0cc6d93e..5e2b075415a109 100644 --- a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.test.ts @@ -835,6 +835,30 @@ function mockStats( runtime: Partial['runtime']['value']> = {} ): CapacityEstimationParams { return { + ephemeral: { + status: HealthStatus.OK, + timestamp: new Date().toISOString(), + value: { + load: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + executionsPerCycle: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + queuedTasks: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + }, + }, configuration: { status: HealthStatus.OK, timestamp: new Date().toISOString(), diff --git a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts index 90f564152c8c7f..03efcff10eb63b 100644 --- a/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts +++ b/x-pack/plugins/task_manager/server/monitoring/capacity_estimation.ts @@ -100,6 +100,7 @@ export function estimateCapacity( percentageOfExecutionsUsedByRecurringTasks + percentageOfExecutionsUsedByNonRecurringTasks ) ); + /** * On average, how much of this kibana's capacity has been historically used to execute * non-recurring and ephemeral tasks @@ -147,7 +148,7 @@ export function estimateCapacity( */ const minRequiredKibanaInstances = Math.ceil( hasTooLittleCapacityToEstimateRequiredNonRecurringCapacity - ? /* + ? /* if load is at 100% or there's no capacity for recurring tasks at the moment, then it's really difficult for us to assess how much capacity is needed for non-recurring tasks at normal times. This might be representative, but it might also be a spike and we have no way of knowing that. We'll recommend people scale up by 20% and go from there. */ @@ -182,7 +183,6 @@ export function estimateCapacity( const assumedRequiredThroughputPerMinutePerKibana = averageCapacityUsedByNonRecurringAndEphemeralTasksPerKibana + averageRecurringRequiredPerMinute / assumedKibanaInstances; - return { status: assumedRequiredThroughputPerMinutePerKibana < capacityPerMinutePerKibana diff --git a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts index 6aa8bad5717ece..82a111305927f2 100644 --- a/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/configuration_statistics.test.ts @@ -35,6 +35,10 @@ describe('Configuration Statistics Aggregator', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, }; const managedConfig = { diff --git a/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.test.ts new file mode 100644 index 00000000000000..1ddfe4bb22088d --- /dev/null +++ b/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.test.ts @@ -0,0 +1,384 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; +import { Subject, Observable } from 'rxjs'; +import stats from 'stats-lite'; +import { take, bufferCount, skip, map } from 'rxjs/operators'; + +import { ConcreteTaskInstance, TaskStatus } from '../task'; +import { + asTaskRunEvent, + TaskTiming, + asTaskManagerStatEvent, + TaskPersistence, +} from '../task_events'; +import { asOk } from '../lib/result_type'; +import { TaskLifecycleEvent } from '../polling_lifecycle'; +import { TaskRunResult } from '../task_running'; +import { + createEphemeralTaskAggregator, + summarizeEphemeralStat, + SummarizedEphemeralTaskStat, + EphemeralTaskStat, +} from './ephemeral_task_statistics'; +import { AggregatedStat } from './runtime_statistics_aggregator'; +import { ephemeralTaskLifecycleMock } from '../ephemeral_task_lifecycle.mock'; +import { times, takeRight, take as takeLeft } from 'lodash'; + +describe('Ephemeral Task Statistics', () => { + test('returns the average size of the ephemeral queue', async () => { + const queueSize = [2, 6, 10, 10, 10, 6, 2, 0, 0]; + const events$ = new Subject(); + const getQueuedTasks = jest.fn(); + const ephemeralTaskLifecycle = ephemeralTaskLifecycleMock.create({ + events$: events$ as Observable, + getQueuedTasks, + }); + + const runningAverageWindowSize = 5; + const ephemeralTaskAggregator = createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + runningAverageWindowSize, + 10 + ); + + function expectWindowEqualsUpdate( + taskStat: AggregatedStat, + window: number[] + ) { + expect(taskStat.value.queuedTasks).toMatchObject({ + p50: stats.percentile(window, 0.5), + p90: stats.percentile(window, 0.9), + p95: stats.percentile(window, 0.95), + p99: stats.percentile(window, 0.99), + }); + } + + return new Promise((resolve) => { + ephemeralTaskAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeEphemeralStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeEphemeralStat(value).value, + })), + take(queueSize.length), + bufferCount(queueSize.length) + ) + .subscribe((taskStats: Array>) => { + expectWindowEqualsUpdate(taskStats[0], queueSize.slice(0, 1)); + expectWindowEqualsUpdate(taskStats[1], queueSize.slice(0, 2)); + expectWindowEqualsUpdate(taskStats[2], queueSize.slice(0, 3)); + expectWindowEqualsUpdate(taskStats[3], queueSize.slice(0, 4)); + expectWindowEqualsUpdate(taskStats[4], queueSize.slice(0, 5)); + // from the 6th value, begin to drop old values as out window is 5 + expectWindowEqualsUpdate(taskStats[5], queueSize.slice(1, 6)); + expectWindowEqualsUpdate(taskStats[6], queueSize.slice(2, 7)); + expectWindowEqualsUpdate(taskStats[7], queueSize.slice(3, 8)); + resolve(); + }); + + for (const size of queueSize) { + events$.next(asTaskManagerStatEvent('queuedEphemeralTasks', asOk(size))); + } + }); + }); + + test('returns the average number of ephemeral tasks executed per polling cycle', async () => { + const tasksQueueSize = [5, 2, 5, 0]; + const executionsPerCycle = [5, 0, 5]; + // we expect one event per "task queue size event", and we simmulate + // tasks being drained after each one of theseevents, so we expect + // the first cycle to show zero drained tasks + const expectedTasksDrainedEvents = [0, ...executionsPerCycle]; + + const events$ = new Subject(); + const getQueuedTasks = jest.fn(); + const ephemeralTaskLifecycle = ephemeralTaskLifecycleMock.create({ + events$: events$ as Observable, + getQueuedTasks, + }); + + const runningAverageWindowSize = 5; + const ephemeralTaskAggregator = createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + runningAverageWindowSize, + 10 + ); + + function expectWindowEqualsUpdate( + taskStat: AggregatedStat, + window: number[] + ) { + expect(taskStat.value.executionsPerCycle).toMatchObject({ + p50: stats.percentile(window, 0.5), + p90: stats.percentile(window, 0.9), + p95: stats.percentile(window, 0.95), + p99: stats.percentile(window, 0.99), + }); + } + + return new Promise((resolve) => { + ephemeralTaskAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeEphemeralStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeEphemeralStat(value).value, + })), + take(tasksQueueSize.length), + bufferCount(tasksQueueSize.length) + ) + .subscribe((taskStats: Array>) => { + taskStats.forEach((taskStat, index) => { + expectWindowEqualsUpdate( + taskStat, + takeRight(takeLeft(expectedTasksDrainedEvents, index + 1), runningAverageWindowSize) + ); + }); + resolve(); + }); + + for (const tasksDrainedInCycle of executionsPerCycle) { + events$.next( + asTaskManagerStatEvent('queuedEphemeralTasks', asOk(tasksQueueSize.shift() ?? 0)) + ); + times(tasksDrainedInCycle, () => { + events$.next(mockTaskRunEvent()); + }); + } + events$.next( + asTaskManagerStatEvent('queuedEphemeralTasks', asOk(tasksQueueSize.shift() ?? 0)) + ); + }); + }); + + test('returns the average load added per polling cycle cycle by ephemeral tasks', async () => { + const tasksExecuted = [0, 5, 10, 10, 10, 5, 5, 0, 0, 0, 0, 0]; + const expectedLoad = [0, 50, 100, 100, 100, 50, 50, 0, 0, 0, 0, 0]; + + const events$ = new Subject(); + const getQueuedTasks = jest.fn(); + const ephemeralTaskLifecycle = ephemeralTaskLifecycleMock.create({ + events$: events$ as Observable, + getQueuedTasks, + }); + + const runningAverageWindowSize = 5; + const maxWorkers = 10; + const ephemeralTaskAggregator = createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + runningAverageWindowSize, + maxWorkers + ); + + function expectWindowEqualsUpdate( + taskStat: AggregatedStat, + window: number[] + ) { + expect(taskStat.value.load).toMatchObject({ + p50: stats.percentile(window, 0.5), + p90: stats.percentile(window, 0.9), + p95: stats.percentile(window, 0.95), + p99: stats.percentile(window, 0.99), + }); + } + + return new Promise((resolve) => { + ephemeralTaskAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeEphemeralStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeEphemeralStat(value).value, + })), + take(tasksExecuted.length), + bufferCount(tasksExecuted.length) + ) + .subscribe((taskStats: Array>) => { + taskStats.forEach((taskStat, index) => { + expectWindowEqualsUpdate( + taskStat, + takeRight(takeLeft(expectedLoad, index + 1), runningAverageWindowSize) + ); + }); + resolve(); + }); + + for (const tasksExecutedInCycle of tasksExecuted) { + times(tasksExecutedInCycle, () => { + events$.next(mockTaskRunEvent()); + }); + events$.next(asTaskManagerStatEvent('queuedEphemeralTasks', asOk(0))); + } + }); + }); +}); + +test('returns the average load added per polling cycle cycle by ephemeral tasks when load exceeds max workers', async () => { + const tasksExecuted = [0, 5, 10, 20, 15, 10, 5, 0, 0, 0, 0, 0]; + const expectedLoad = [0, 50, 100, 200, 150, 100, 50, 0, 0, 0, 0, 0]; + + const events$ = new Subject(); + const getQueuedTasks = jest.fn(); + const ephemeralTaskLifecycle = ephemeralTaskLifecycleMock.create({ + events$: events$ as Observable, + getQueuedTasks, + }); + + const runningAverageWindowSize = 5; + const maxWorkers = 10; + const ephemeralTaskAggregator = createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + runningAverageWindowSize, + maxWorkers + ); + + function expectWindowEqualsUpdate( + taskStat: AggregatedStat, + window: number[] + ) { + expect(taskStat.value.load).toMatchObject({ + p50: stats.percentile(window, 0.5), + p90: stats.percentile(window, 0.9), + p95: stats.percentile(window, 0.95), + p99: stats.percentile(window, 0.99), + }); + } + + return new Promise((resolve) => { + ephemeralTaskAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeEphemeralStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeEphemeralStat(value).value, + })), + take(tasksExecuted.length), + bufferCount(tasksExecuted.length) + ) + .subscribe((taskStats: Array>) => { + taskStats.forEach((taskStat, index) => { + expectWindowEqualsUpdate( + taskStat, + takeRight(takeLeft(expectedLoad, index + 1), runningAverageWindowSize) + ); + }); + resolve(); + }); + + for (const tasksExecutedInCycle of tasksExecuted) { + times(tasksExecutedInCycle, () => { + events$.next(mockTaskRunEvent()); + }); + events$.next(asTaskManagerStatEvent('queuedEphemeralTasks', asOk(0))); + } + }); +}); + +test('returns the average delay experienced by tasks in the ephemeral queue', async () => { + const taskDelays = [100, 150, 500, 100, 100, 200, 2000, 10000, 20000, 100]; + + const events$ = new Subject(); + const getQueuedTasks = jest.fn(); + const ephemeralTaskLifecycle = ephemeralTaskLifecycleMock.create({ + events$: events$ as Observable, + getQueuedTasks, + }); + + const runningAverageWindowSize = 5; + const ephemeralTaskAggregator = createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + runningAverageWindowSize, + 10 + ); + + function expectWindowEqualsUpdate( + taskStat: AggregatedStat, + window: number[] + ) { + expect(taskStat.value.delay).toMatchObject({ + p50: stats.percentile(window, 0.5), + p90: stats.percentile(window, 0.9), + p95: stats.percentile(window, 0.95), + p99: stats.percentile(window, 0.99), + }); + } + + return new Promise((resolve) => { + ephemeralTaskAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeEphemeralStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeEphemeralStat(value).value, + })), + take(taskDelays.length), + bufferCount(taskDelays.length) + ) + .subscribe((taskStats: Array>) => { + taskStats.forEach((taskStat, index) => { + expectWindowEqualsUpdate( + taskStat, + takeRight(takeLeft(taskDelays, index + 1), runningAverageWindowSize) + ); + }); + resolve(); + }); + + for (const delay of taskDelays) { + events$.next(asTaskManagerStatEvent('ephemeralTaskDelay', asOk(delay))); + } + }); +}); + +const mockTaskRunEvent = ( + overrides: Partial = {}, + timing: TaskTiming = { + start: 0, + stop: 0, + }, + result: TaskRunResult = TaskRunResult.Success +) => { + const task = mockTaskInstance(overrides); + const persistence = TaskPersistence.Recurring; + return asTaskRunEvent(task.id, asOk({ task, persistence, result }), timing); +}; + +const mockTaskInstance = (overrides: Partial = {}): ConcreteTaskInstance => ({ + id: uuid.v4(), + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + ...overrides, +}); diff --git a/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.ts new file mode 100644 index 00000000000000..d1f3ef9c140557 --- /dev/null +++ b/x-pack/plugins/task_manager/server/monitoring/ephemeral_task_statistics.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { map, filter, startWith, buffer, share } from 'rxjs/operators'; +import { JsonObject } from '@kbn/common-utils'; +import { combineLatest, Observable, zip } from 'rxjs'; +import { isOk, Ok } from '../lib/result_type'; +import { AggregatedStat, AggregatedStatProvider } from './runtime_statistics_aggregator'; +import { EphemeralTaskLifecycle } from '../ephemeral_task_lifecycle'; +import { TaskLifecycleEvent } from '../polling_lifecycle'; +import { isTaskRunEvent, isTaskManagerStatEvent } from '../task_events'; +import { + AveragedStat, + calculateRunningAverage, + createRunningAveragedStat, +} from './task_run_calcultors'; +import { HealthStatus } from './monitoring_stats_stream'; + +export interface EphemeralTaskStat extends JsonObject { + queuedTasks: number[]; + executionsPerCycle: number[]; + load: number[]; + delay: number[]; +} + +export interface SummarizedEphemeralTaskStat extends JsonObject { + queuedTasks: AveragedStat; + executionsPerCycle: AveragedStat; + load: AveragedStat; +} +export function createEphemeralTaskAggregator( + ephemeralTaskLifecycle: EphemeralTaskLifecycle, + runningAverageWindowSize: number, + maxWorkers: number +): AggregatedStatProvider { + const ephemeralTaskRunEvents$ = ephemeralTaskLifecycle.events.pipe( + filter((taskEvent: TaskLifecycleEvent) => isTaskRunEvent(taskEvent)) + ); + + const ephemeralQueueSizeEvents$: Observable = ephemeralTaskLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => + isTaskManagerStatEvent(taskEvent) && + taskEvent.id === 'queuedEphemeralTasks' && + isOk(taskEvent.event) + ), + map((taskEvent: TaskLifecycleEvent) => { + return ((taskEvent.event as unknown) as Ok).value; + }), + // as we consume this stream twice below (in the buffer, and the zip) + // we want to use share, otherwise ther'll be 2 subscribers and both will emit event + share() + ); + + const ephemeralQueueExecutionsPerCycleQueue = createRunningAveragedStat( + runningAverageWindowSize + ); + const ephemeralQueuedTasksQueue = createRunningAveragedStat(runningAverageWindowSize); + const ephemeralTaskLoadQueue = createRunningAveragedStat(runningAverageWindowSize); + const ephemeralPollingCycleBasedStats$ = zip( + ephemeralTaskRunEvents$.pipe( + buffer(ephemeralQueueSizeEvents$), + map((taskEvents: TaskLifecycleEvent[]) => taskEvents.length) + ), + ephemeralQueueSizeEvents$ + ).pipe( + map(([tasksRanSincePreviousQueueSize, ephemeralQueueSize]) => ({ + queuedTasks: ephemeralQueuedTasksQueue(ephemeralQueueSize), + executionsPerCycle: ephemeralQueueExecutionsPerCycleQueue(tasksRanSincePreviousQueueSize), + load: ephemeralTaskLoadQueue(calculateWorkerLoad(maxWorkers, tasksRanSincePreviousQueueSize)), + })), + startWith({ + queuedTasks: [], + executionsPerCycle: [], + load: [], + }) + ); + + const ephemeralTaskDelayQueue = createRunningAveragedStat(runningAverageWindowSize); + const ephemeralTaskDelayEvents$: Observable = ephemeralTaskLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => + isTaskManagerStatEvent(taskEvent) && + taskEvent.id === 'ephemeralTaskDelay' && + isOk(taskEvent.event) + ), + map((taskEvent: TaskLifecycleEvent) => { + return ephemeralTaskDelayQueue(((taskEvent.event as unknown) as Ok).value); + }), + startWith([]) + ); + + return combineLatest([ephemeralPollingCycleBasedStats$, ephemeralTaskDelayEvents$]).pipe( + map(([stats, delay]: [Omit, EphemeralTaskStat['delay']]) => { + return { + key: 'ephemeral', + value: { ...stats, delay }, + } as AggregatedStat; + }) + ); +} + +function calculateWorkerLoad(maxWorkers: number, tasksExecuted: number) { + return Math.round((tasksExecuted * 100) / maxWorkers); +} + +export function summarizeEphemeralStat({ + queuedTasks, + executionsPerCycle, + load, + delay, +}: EphemeralTaskStat): { value: SummarizedEphemeralTaskStat; status: HealthStatus } { + return { + value: { + queuedTasks: calculateRunningAverage(queuedTasks.length ? queuedTasks : [0]), + load: calculateRunningAverage(load.length ? load : [0]), + executionsPerCycle: calculateRunningAverage( + executionsPerCycle.length ? executionsPerCycle : [0] + ), + delay: calculateRunningAverage(delay.length ? delay : [0]), + }, + status: HealthStatus.OK, + }; +} diff --git a/x-pack/plugins/task_manager/server/monitoring/index.ts b/x-pack/plugins/task_manager/server/monitoring/index.ts index 802a60b82ced19..99a4e31dbdb02e 100644 --- a/x-pack/plugins/task_manager/server/monitoring/index.ts +++ b/x-pack/plugins/task_manager/server/monitoring/index.ts @@ -16,6 +16,7 @@ import { import { TaskStore } from '../task_store'; import { TaskPollingLifecycle } from '../polling_lifecycle'; import { ManagedConfiguration } from '../lib/create_managed_configuration'; +import { EphemeralTaskLifecycle } from '../ephemeral_task_lifecycle'; export { MonitoringStats, @@ -28,6 +29,7 @@ export { export function createMonitoringStats( taskPollingLifecycle: TaskPollingLifecycle, + ephemeralTaskLifecycle: EphemeralTaskLifecycle, taskStore: TaskStore, elasticsearchAndSOAvailability$: Observable, config: TaskManagerConfig, @@ -37,6 +39,7 @@ export function createMonitoringStats( return createMonitoringStatsStream( createAggregators( taskPollingLifecycle, + ephemeralTaskLifecycle, taskStore, elasticsearchAndSOAvailability$, config, diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts index 2e53850814e832..8e615fb861717a 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.test.ts @@ -39,6 +39,10 @@ describe('createMonitoringStatsStream', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, }; it('returns the initial config used to configure Task Manager', async () => { diff --git a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.ts b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.ts index 0d3b6ebf56de65..b187faf9e96485 100644 --- a/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.ts +++ b/x-pack/plugins/task_manager/server/monitoring/monitoring_stats_stream.ts @@ -18,6 +18,12 @@ import { SummarizedWorkloadStat, WorkloadStat, } from './workload_statistics'; +import { + EphemeralTaskStat, + createEphemeralTaskAggregator, + SummarizedEphemeralTaskStat, + summarizeEphemeralStat, +} from './ephemeral_task_statistics'; import { createTaskRunAggregator, summarizeTaskRunStat, @@ -28,6 +34,7 @@ import { ConfigStat, createConfigurationAggregator } from './configuration_stati import { TaskManagerConfig } from '../config'; import { AggregatedStatProvider } from './runtime_statistics_aggregator'; import { ManagedConfiguration } from '../lib/create_managed_configuration'; +import { EphemeralTaskLifecycle } from '../ephemeral_task_lifecycle'; import { CapacityEstimationStat, withCapacityEstimate } from './capacity_estimation'; export { AggregatedStatProvider, AggregatedStat } from './runtime_statistics_aggregator'; @@ -38,6 +45,7 @@ export interface MonitoringStats { configuration?: MonitoredStat; workload?: MonitoredStat; runtime?: MonitoredStat; + ephemeral?: MonitoredStat; }; } @@ -61,19 +69,21 @@ export interface RawMonitoringStats { configuration?: RawMonitoredStat; workload?: RawMonitoredStat; runtime?: RawMonitoredStat; + ephemeral?: RawMonitoredStat; capacity_estimation?: RawMonitoredStat; }; } export function createAggregators( taskPollingLifecycle: TaskPollingLifecycle, + ephemeralTaskLifecycle: EphemeralTaskLifecycle, taskStore: TaskStore, elasticsearchAndSOAvailability$: Observable, config: TaskManagerConfig, managedConfig: ManagedConfiguration, logger: Logger ): AggregatedStatProvider { - return merge( + const aggregators: AggregatedStatProvider[] = [ createConfigurationAggregator(config, managedConfig), createTaskRunAggregator(taskPollingLifecycle, config.monitored_stats_running_average_window), createWorkloadAggregator( @@ -82,8 +92,18 @@ export function createAggregators( config.monitored_aggregated_stats_refresh_rate, config.poll_interval, logger - ) - ); + ), + ]; + if (ephemeralTaskLifecycle.enabled) { + aggregators.push( + createEphemeralTaskAggregator( + ephemeralTaskLifecycle, + config.monitored_stats_running_average_window, + config.max_workers + ) + ); + } + return merge(...aggregators); } export function createMonitoringStatsStream( @@ -119,7 +139,7 @@ export function summarizeMonitoringStats( { // eslint-disable-next-line @typescript-eslint/naming-convention last_update, - stats: { runtime, workload, configuration }, + stats: { runtime, workload, configuration, ephemeral }, }: MonitoringStats, config: TaskManagerConfig ): RawMonitoringStats { @@ -148,6 +168,14 @@ export function summarizeMonitoringStats( }, } : {}), + ...(ephemeral + ? { + ephemeral: { + timestamp: ephemeral.timestamp, + ...summarizeEphemeralStat(ephemeral.value), + }, + } + : {}), }); return { diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts index 38fdc89278e893..46dc56b2bac4d5 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.test.ts @@ -17,6 +17,8 @@ import { asTaskPollingCycleEvent, TaskTiming, asTaskManagerStatEvent, + TaskPersistence, + asTaskClaimEvent, } from '../task_events'; import { asOk } from '../lib/result_type'; import { TaskLifecycleEvent } from '../polling_lifecycle'; @@ -400,6 +402,44 @@ describe('Task Run Statistics', () => { runningAverageWindowSize ); + const taskEvents = [ + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success), + mockTaskRunEvent( + { schedule: { interval: '3s' } }, + { start: 0, stop: 0 }, + TaskRunResult.Success + ), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Failed), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Failed), + mockTaskRunEvent( + { schedule: { interval: '3s' } }, + { start: 0, stop: 0 }, + TaskRunResult.Failed + ), + mockTaskRunEvent( + { schedule: { interval: '3s' } }, + { start: 0, stop: 0 }, + TaskRunResult.RetryScheduled + ), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.RetryScheduled), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success), + mockTaskRunEvent( + { schedule: { interval: '3s' } }, + { start: 0, stop: 0 }, + TaskRunResult.Success + ), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success, TaskPersistence.Ephemeral), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success, TaskPersistence.Ephemeral), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success), + mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success, TaskPersistence.Ephemeral), + mockTaskRunEvent( + { schedule: { interval: '3s' } }, + { start: 0, stop: 0 }, + TaskRunResult.Success + ), + ]; + return new Promise((resolve, reject) => { taskRunAggregator .pipe( @@ -409,22 +449,10 @@ describe('Task Run Statistics', () => { // Use 'summarizeTaskRunStat' to receive summarize stats map(({ key, value }: AggregatedStat) => ({ key, - value: summarizeTaskRunStat( - value, - getTaskManagerConfig({ - monitored_task_execution_thresholds: { - custom: { - 'alerting:test': { - error_threshold: 59, - warn_threshold: 39, - }, - }, - }, - }) - ).value, + value: summarizeTaskRunStat(value, getTaskManagerConfig({})).value, })), - take(10), - bufferCount(10) + take(taskEvents.length), + bufferCount(taskEvents.length) ) .subscribe((taskStats: Array>) => { try { @@ -485,6 +513,31 @@ describe('Task Run Statistics', () => { "non_recurring": 40, "recurring": 60, }, + Object { + "ephemeral": 20, + "non_recurring": 40, + "recurring": 40, + }, + Object { + "ephemeral": 40, + "non_recurring": 40, + "recurring": 20, + }, + Object { + "ephemeral": 40, + "non_recurring": 40, + "recurring": 20, + }, + Object { + "ephemeral": 60, + "non_recurring": 20, + "recurring": 20, + }, + Object { + "ephemeral": 60, + "non_recurring": 20, + "recurring": 20, + }, ] `); resolve(); @@ -493,40 +546,142 @@ describe('Task Run Statistics', () => { } }); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success)); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success)); - events$.next( - mockTaskRunEvent( - { schedule: { interval: '3s' } }, - { start: 0, stop: 0 }, - TaskRunResult.Success - ) - ); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Failed)); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Failed)); - events$.next( - mockTaskRunEvent( - { schedule: { interval: '3s' } }, - { start: 0, stop: 0 }, - TaskRunResult.Failed - ) - ); - events$.next( - mockTaskRunEvent( - { schedule: { interval: '3s' } }, - { start: 0, stop: 0 }, - TaskRunResult.RetryScheduled + taskEvents.forEach((event) => events$.next(event)); + }); + }); + + test('frequency of polled tasks by their persistence', async () => { + const events$ = new Subject(); + + const taskPollingLifecycle = taskPollingLifecycleMock.create({ + events$: events$ as Observable, + }); + + const runningAverageWindowSize = 5; + const taskRunAggregator = createTaskRunAggregator( + taskPollingLifecycle, + runningAverageWindowSize + ); + + const taskEvents = [ + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({ schedule: { interval: '3s' } }), + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({ schedule: { interval: '3s' } }), + mockTaskPollingEvent({ schedule: { interval: '3s' } }), + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({ schedule: { interval: '3s' } }), + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({}), + mockTaskPollingEvent({ schedule: { interval: '3s' } }), + ]; + + return new Promise((resolve, reject) => { + taskRunAggregator + .pipe( + // skip initial stat which is just initialized data which + // ensures we don't stall on combineLatest + skip(1), + // Use 'summarizeTaskRunStat' to receive summarize stats + map(({ key, value }: AggregatedStat) => ({ + key, + value: summarizeTaskRunStat(value, getTaskManagerConfig({})).value, + })), + take(taskEvents.length), + bufferCount(taskEvents.length) ) - ); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.RetryScheduled)); - events$.next(mockTaskRunEvent({}, { start: 0, stop: 0 }, TaskRunResult.Success)); + .subscribe((taskStats: Array>) => { + try { + /** + * At any given time we only keep track of the last X Polling Results + * In the tests this is ocnfiugured to a window size of 5 + */ + expect(taskStats.map((taskStat) => taskStat.value.polling.persistence)) + .toMatchInlineSnapshot(` + Array [ + Object { + "non_recurring": 0, + "recurring": 0, + }, + Object { + "non_recurring": 100, + "recurring": 0, + }, + Object { + "non_recurring": 100, + "recurring": 0, + }, + Object { + "non_recurring": 67, + "recurring": 33, + }, + Object { + "non_recurring": 75, + "recurring": 25, + }, + Object { + "non_recurring": 80, + "recurring": 20, + }, + Object { + "non_recurring": 60, + "recurring": 40, + }, + Object { + "non_recurring": 40, + "recurring": 60, + }, + Object { + "non_recurring": 60, + "recurring": 40, + }, + Object { + "non_recurring": 60, + "recurring": 40, + }, + Object { + "non_recurring": 40, + "recurring": 60, + }, + Object { + "non_recurring": 60, + "recurring": 40, + }, + Object { + "non_recurring": 80, + "recurring": 20, + }, + Object { + "non_recurring": 80, + "recurring": 20, + }, + Object { + "non_recurring": 80, + "recurring": 20, + }, + ] + `); + resolve(); + } catch (e) { + reject(e); + } + }); + + const timing = { + start: 0, + stop: 0, + }; events$.next( - mockTaskRunEvent( - { schedule: { interval: '3s' } }, - { start: 0, stop: 0 }, - TaskRunResult.Success - ) + asTaskPollingCycleEvent(asOk({ result: FillPoolResult.NoTasksClaimed, timing })) ); + events$.next(asTaskManagerStatEvent('pollingDelay', asOk(0))); + events$.next(asTaskManagerStatEvent('claimDuration', asOk(10))); + taskEvents.forEach((event) => events$.next(event)); }); }); @@ -713,10 +868,25 @@ function runAtMillisecondsAgo(ms: number): Date { const mockTaskRunEvent = ( overrides: Partial = {}, timing: TaskTiming, - result: TaskRunResult = TaskRunResult.Success + result: TaskRunResult = TaskRunResult.Success, + persistence?: TaskPersistence ) => { const task = mockTaskInstance(overrides); - return asTaskRunEvent(task.id, asOk({ task, result }), timing); + return asTaskRunEvent( + task.id, + asOk({ + task, + persistence: + persistence ?? (task.schedule ? TaskPersistence.Recurring : TaskPersistence.NonRecurring), + result, + }), + timing + ); +}; + +const mockTaskPollingEvent = (overrides: Partial = {}) => { + const task = mockTaskInstance(overrides); + return asTaskClaimEvent(task.id, asOk(task)); }; const mockTaskInstance = (overrides: Partial = {}): ConcreteTaskInstance => ({ diff --git a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts index da86cfad2a911e..d43137d237a997 100644 --- a/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/task_run_statistics.ts @@ -20,6 +20,9 @@ import { TaskTiming, isTaskManagerStatEvent, TaskManagerStat, + TaskPersistence, + TaskClaim, + isTaskClaimEvent, } from '../task_events'; import { isOk, Ok, unwrap } from '../lib/result_type'; import { ConcreteTaskInstance } from '../task'; @@ -36,24 +39,17 @@ import { HealthStatus } from './monitoring_stats_stream'; import { TaskPollingLifecycle } from '../polling_lifecycle'; import { TaskExecutionFailureThreshold, TaskManagerConfig } from '../config'; -export enum TaskPersistence { - Recurring = 'recurring', - NonRecurring = 'non_recurring', - Ephemeral = 'ephemeral', -} - -function persistenceOf(task: ConcreteTaskInstance) { - return task.schedule ? TaskPersistence.Recurring : TaskPersistence.NonRecurring; -} - interface FillPoolStat extends JsonObject { - last_successful_poll: string; - last_polling_delay: string; duration: number[]; claim_duration: number[]; claim_conflicts: number[]; claim_mismatches: number[]; result_frequency_percent_as_number: FillPoolResult[]; + persistence: TaskPersistence[]; +} +interface OptionalFillPoolStat extends JsonObject { + last_successful_poll: string; + last_polling_delay: string; } interface ExecutionStat extends JsonObject { @@ -68,8 +64,7 @@ export interface TaskRunStat extends JsonObject { drift_by_type: Record; load: number[]; execution: ExecutionStat; - polling: Omit & - Pick, 'last_successful_poll' | 'last_polling_delay'>; + polling: FillPoolStat & Partial; } interface FillPoolRawStat extends JsonObject { @@ -83,6 +78,7 @@ interface FillPoolRawStat extends JsonObject { [FillPoolResult.RunningAtCapacity]: number; [FillPoolResult.PoolFilled]: number; }; + persistence: TaskPersistenceTypes; } interface ResultFrequency extends JsonObject { @@ -126,8 +122,10 @@ export function createTaskRunAggregator( > = taskPollingLifecycle.events.pipe( filter((taskEvent: TaskLifecycleEvent) => isTaskRunEvent(taskEvent) && hasTiming(taskEvent)), map((taskEvent: TaskLifecycleEvent) => { - const { task, result }: RanTask | ErroredTask = unwrap((taskEvent as TaskRun).event); - return taskRunEventToStat(task, taskEvent.timing!, result); + const { task, result, persistence }: RanTask | ErroredTask = unwrap( + (taskEvent as TaskRun).event + ); + return taskRunEventToStat(task, persistence, taskEvent.timing!, result); }) ); @@ -153,6 +151,9 @@ export function createTaskRunAggregator( const claimDurationQueue = createRunningAveragedStat(runningAverageWindowSize); const claimConflictsQueue = createRunningAveragedStat(runningAverageWindowSize); const claimMismatchesQueue = createRunningAveragedStat(runningAverageWindowSize); + const polledTasksByPersistenceQueue = createRunningAveragedStat( + runningAverageWindowSize + ); const taskPollingEvents$: Observable> = combineLatest([ // get latest polling stats taskPollingLifecycle.events.pipe( @@ -194,6 +195,22 @@ export function createTaskRunAggregator( ), map(() => new Date().toISOString()) ), + // get the average ratio of polled tasks by their persistency + taskPollingLifecycle.events.pipe( + filter( + (taskEvent: TaskLifecycleEvent) => isTaskClaimEvent(taskEvent) && isOk(taskEvent.event) + ), + map((taskClaimEvent) => { + const claimedTask = ((taskClaimEvent as TaskClaim).event as Ok).value; + return polledTasksByPersistenceQueue( + claimedTask.schedule ? TaskPersistence.Recurring : TaskPersistence.NonRecurring + ); + }), + // unlike the other streams that emit once TM polls, this will only emit when a task is actually + // claimed, so to make sure `combineLatest` doesn't stall until a task is actually emitted we seed + // the stream with an empty queue + startWith([]) + ), // get duration of task claim stage in polling taskPollingLifecycle.events.pipe( filter( @@ -204,16 +221,15 @@ export function createTaskRunAggregator( ), map((claimDurationEvent) => { const duration = ((claimDurationEvent as TaskManagerStat).event as Ok).value; - return { - claimDuration: duration ? claimDurationQueue(duration) : claimDurationQueue(), - }; + return duration ? claimDurationQueue(duration) : claimDurationQueue(); }) ), ]).pipe( - map(([{ polling }, pollingDelay, { claimDuration }]) => ({ + map(([{ polling }, pollingDelay, persistence, claimDuration]) => ({ polling: { last_polling_delay: pollingDelay, claim_duration: claimDuration, + persistence, ...polling, }, })) @@ -245,13 +261,14 @@ export function createTaskRunAggregator( claim_conflicts: [], claim_mismatches: [], result_frequency_percent_as_number: [], + persistence: [], }, }) ), ]).pipe( map( ([taskRun, load, polling]: [ - Pick, + Pick, Pick, Pick ]) => { @@ -285,12 +302,12 @@ function createTaskRunEventToStat(runningAverageWindowSize: number) { ); return ( task: ConcreteTaskInstance, + persistence: TaskPersistence, timing: TaskTiming, result: TaskRunResult ): Pick => { const drift = timing!.start - task.runAt.getTime(); const duration = timing!.stop - timing!.start; - const persistence = persistenceOf(task); return { drift: driftQueue(drift), drift_by_type: driftByTaskQueue(task.taskType, drift), @@ -318,11 +335,6 @@ const DEFAULT_POLLING_FREQUENCIES = { [FillPoolResult.RunningAtCapacity]: 0, [FillPoolResult.PoolFilled]: 0, }; -const DEFAULT_PERSISTENCE_FREQUENCIES = { - [TaskPersistence.Recurring]: 0, - [TaskPersistence.NonRecurring]: 0, - [TaskPersistence.Ephemeral]: 0, -}; export function summarizeTaskRunStat( { @@ -337,6 +349,7 @@ export function summarizeTaskRunStat( result_frequency_percent_as_number: pollingResultFrequency, claim_conflicts: claimConflicts, claim_mismatches: claimMismatches, + persistence: pollingPersistence, }, drift, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -366,6 +379,11 @@ export function summarizeTaskRunStat( ...DEFAULT_POLLING_FREQUENCIES, ...calculateFrequency(pollingResultFrequency as FillPoolResult[]), }, + persistence: { + [TaskPersistence.Recurring]: 0, + [TaskPersistence.NonRecurring]: 0, + ...calculateFrequency(pollingPersistence as TaskPersistence[]), + }, }, drift: calculateRunningAverage(drift), drift_by_type: mapValues(drift_by_type, (typedDrift) => calculateRunningAverage(typedDrift)), @@ -376,7 +394,9 @@ export function summarizeTaskRunStat( calculateRunningAverage(typedDurations) ), persistence: { - ...DEFAULT_PERSISTENCE_FREQUENCIES, + [TaskPersistence.Recurring]: 0, + [TaskPersistence.NonRecurring]: 0, + [TaskPersistence.Ephemeral]: 0, ...calculateFrequency(persistence), }, result_frequency_percent_as_number: mapValues( diff --git a/x-pack/plugins/task_manager/server/plugin.test.ts b/x-pack/plugins/task_manager/server/plugin.test.ts index 0d9f285164f10b..dff94259dbe62f 100644 --- a/x-pack/plugins/task_manager/server/plugin.test.ts +++ b/x-pack/plugins/task_manager/server/plugin.test.ts @@ -38,12 +38,18 @@ describe('TaskManagerPlugin', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: false, + request_capacity: 10, + }, }); pluginInitializerContext.env.instanceUuid = ''; const taskManagerPlugin = new TaskManagerPlugin(pluginInitializerContext); - expect(() => taskManagerPlugin.setup(coreMock.createSetup())).toThrow( + expect(() => + taskManagerPlugin.setup(coreMock.createSetup(), { usageCollection: undefined }) + ).toThrow( new Error(`TaskManager is unable to start as Kibana has no valid UUID assigned to it.`) ); }); @@ -72,11 +78,17 @@ describe('TaskManagerPlugin', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, }); const taskManagerPlugin = new TaskManagerPlugin(pluginInitializerContext); - const setupApi = await taskManagerPlugin.setup(coreMock.createSetup()); + const setupApi = await taskManagerPlugin.setup(coreMock.createSetup(), { + usageCollection: undefined, + }); // we only start a poller if we have task types that we support and we track // phases (moving from Setup to Start) based on whether the poller is working diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index d3e251b751ef8c..3d3d180fc06653 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -7,6 +7,7 @@ import { combineLatest, Observable, Subject } from 'rxjs'; import { map, distinctUntilChanged } from 'rxjs/operators'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { PluginInitializerContext, Plugin, @@ -27,6 +28,9 @@ import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; import { healthRoute } from './routes'; import { createMonitoringStats, MonitoringStats } from './monitoring'; +import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle'; +import { EphemeralTask } from './task'; +import { registerTaskManagerUsageCollector } from './usage'; export type TaskManagerSetupContract = { /** @@ -38,15 +42,16 @@ export type TaskManagerSetupContract = { export type TaskManagerStartContract = Pick< TaskScheduling, - 'schedule' | 'runNow' | 'ensureScheduled' + 'schedule' | 'runNow' | 'ephemeralRunNow' | 'ensureScheduled' > & Pick & { removeIfExists: TaskStore['remove']; - }; + } & { supportsEphemeralTasks: () => boolean }; export class TaskManagerPlugin implements Plugin { private taskPollingLifecycle?: TaskPollingLifecycle; + private ephemeralTaskLifecycle?: EphemeralTaskLifecycle; private taskManagerId?: string; private config: TaskManagerConfig; private logger: Logger; @@ -62,7 +67,10 @@ export class TaskManagerPlugin this.definitions = new TaskTypeDictionary(this.logger); } - public setup(core: CoreSetup): TaskManagerSetupContract { + public setup( + core: CoreSetup, + plugins: { usageCollection?: UsageCollectionSetup } + ): TaskManagerSetupContract { this.elasticsearchAndSOAvailability$ = getElasticsearchAndSOAvailability(core.status.core$); setupSavedObjects(core.savedObjects, this.config); @@ -79,7 +87,7 @@ export class TaskManagerPlugin // Routes const router = core.http.createRouter(); - const serviceStatus$ = healthRoute( + const { serviceStatus$, monitoredHealth$ } = healthRoute( router, this.monitoringStats$, this.logger, @@ -95,6 +103,16 @@ export class TaskManagerPlugin ) ); + const usageCollection = plugins.usageCollection; + if (usageCollection) { + registerTaskManagerUsageCollector( + usageCollection, + monitoredHealth$, + this.config.ephemeral_tasks.enabled, + this.config.ephemeral_tasks.request_capacity + ); + } + return { index: this.config.index, addMiddleware: (middleware: Middleware) => { @@ -138,8 +156,19 @@ export class TaskManagerPlugin ...managedConfiguration, }); + this.ephemeralTaskLifecycle = new EphemeralTaskLifecycle({ + config: this.config!, + definitions: this.definitions, + logger: this.logger, + middleware: this.middleware, + elasticsearchAndSOAvailability$: this.elasticsearchAndSOAvailability$!, + pool: this.taskPollingLifecycle.pool, + lifecycleEvent: this.taskPollingLifecycle.events, + }); + createMonitoringStats( this.taskPollingLifecycle, + this.ephemeralTaskLifecycle, taskStore, this.elasticsearchAndSOAvailability$!, this.config!, @@ -152,7 +181,9 @@ export class TaskManagerPlugin taskStore, middleware: this.middleware, taskPollingLifecycle: this.taskPollingLifecycle, + ephemeralTaskLifecycle: this.ephemeralTaskLifecycle, definitions: this.definitions, + taskManagerId: taskStore.taskManagerId, }); return { @@ -163,6 +194,8 @@ export class TaskManagerPlugin schedule: (...args) => taskScheduling.schedule(...args), ensureScheduled: (...args) => taskScheduling.ensureScheduled(...args), runNow: (...args) => taskScheduling.runNow(...args), + ephemeralRunNow: (task: EphemeralTask) => taskScheduling.ephemeralRunNow(task), + supportsEphemeralTasks: () => this.config.ephemeral_tasks.enabled, }; } diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index 73b892c9f59e09..aad03951bbb9b1 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -58,6 +58,10 @@ describe('TaskPollingLifecycle', () => { }, custom: {}, }, + ephemeral_tasks: { + enabled: true, + request_capacity: 10, + }, }, taskStore: mockTaskStore, logger: taskManagerLogger, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index 454e49fe3f8681..16b15d0c46e3ea 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -25,6 +25,7 @@ import { asTaskPollingCycleEvent, TaskManagerStat, asTaskManagerStatEvent, + EphemeralTaskRejectedDueToCapacity, } from './task_events'; import { fillPool, FillPoolResult, TimedFillPoolResult } from './lib/fill_pool'; import { Middleware } from './lib/middleware'; @@ -60,7 +61,8 @@ export type TaskLifecycleEvent = | TaskClaim | TaskRunRequest | TaskPollingCycle - | TaskManagerStat; + | TaskManagerStat + | EphemeralTaskRejectedDueToCapacity; /** * The public interface into the task manager system. @@ -73,7 +75,7 @@ export class TaskPollingLifecycle { private bufferedStore: BufferedTaskStore; private logger: Logger; - private pool: TaskPool; + public pool: TaskPool; // all task related events (task claimed, task marked as running, etc.) are emitted through events$ private events$ = new Subject(); // all on-demand requests we wish to pipe into the poller @@ -160,7 +162,15 @@ export class TaskPollingLifecycle { pollInterval$: pollIntervalConfiguration$, pollIntervalDelay$, bufferCapacity: config.request_capacity, - getCapacity: () => this.pool.availableWorkers, + getCapacity: () => { + const capacity = this.pool.availableWorkers; + if (!capacity) { + // if there isn't capacity, emit a load event so that we can expose how often + // high load causes the poller to skip work (work isn'tcalled when there is no capacity) + this.emitEvent(asTaskManagerStatEvent('load', asOk(this.pool.workerLoad))); + } + return capacity; + }, pollRequests$: this.claimRequests$, work: this.pollForWork, // Time out the `work` phase if it takes longer than a certain number of polling cycles @@ -227,8 +237,8 @@ export class TaskPollingLifecycle { private pollForWork = async (...tasksToClaim: string[]): Promise => { return fillPool( // claim available tasks - () => - claimAvailableTasks( + () => { + return claimAvailableTasks( tasksToClaim.splice(0, this.pool.availableWorkers), this.taskClaiming, this.logger @@ -242,11 +252,18 @@ export class TaskPollingLifecycle { } }) ) - ), + ); + }, // wrap each task in a Task Runner this.createTaskRunnerForTask, // place tasks in the Task Pool - async (tasks: TaskRunner[]) => await this.pool.run(tasks) + async (tasks: TaskRunner[]) => { + const result = await this.pool.run(tasks); + // Emit the load after fetching tasks, giving us a good metric for evaluating how + // busy Task manager tends to be in this Kibana instance + this.emitEvent(asTaskManagerStatEvent('load', asOk(this.pool.workerLoad))); + return result; + } ); }; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts index 7f15707a14b308..20a0275d8fa07c 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -11,7 +11,7 @@ import apm from 'elastic-apm-node'; import { Subject, Observable, from, of } from 'rxjs'; import { map, mergeScan } from 'rxjs/operators'; -import { difference, partition, groupBy, mapValues, countBy, pick } from 'lodash'; +import { difference, partition, groupBy, mapValues, countBy, pick, isPlainObject } from 'lodash'; import { some, none } from 'fp-ts/lib/Option'; import { Logger } from '../../../../../src/core/server'; @@ -87,6 +87,9 @@ export interface ClaimOwnershipResult { docs: ConcreteTaskInstance[]; timing?: TaskTiming; } +export const isClaimOwnershipResult = (result: unknown): result is ClaimOwnershipResult => + isPlainObject((result as ClaimOwnershipResult).stats) && + Array.isArray((result as ClaimOwnershipResult).docs); enum BatchConcurrency { Unlimited, diff --git a/x-pack/plugins/task_manager/server/routes/health.test.ts b/x-pack/plugins/task_manager/server/routes/health.test.ts index ece91ed571f880..fd7e37e0fe9a58 100644 --- a/x-pack/plugins/task_manager/server/routes/health.test.ts +++ b/x-pack/plugins/task_manager/server/routes/health.test.ts @@ -23,6 +23,7 @@ import { import { ServiceStatusLevels } from 'src/core/server'; import { configSchema, TaskManagerConfig } from '../config'; import { calculateHealthStatusMock } from '../lib/calculate_health_status.mock'; +import { FillPoolResult } from '../lib/fill_pool'; jest.mock('../lib/log_health_metrics', () => ({ logHealthMetrics: jest.fn(), @@ -106,6 +107,7 @@ describe('healthRoute', () => { const warnRuntimeStat = mockHealthStats(); const warnConfigurationStat = mockHealthStats(); const warnWorkloadStat = mockHealthStats(); + const warnEphemeralStat = mockHealthStats(); const stats$ = new Subject(); @@ -130,8 +132,10 @@ describe('healthRoute', () => { stats$.next(warnConfigurationStat); await sleep(1001); stats$.next(warnWorkloadStat); + await sleep(1001); + stats$.next(warnEphemeralStat); - expect(logHealthMetrics).toBeCalledTimes(3); + expect(logHealthMetrics).toBeCalledTimes(4); expect(logHealthMetrics.mock.calls[0][0]).toMatchObject({ id, timestamp: expect.any(String), @@ -156,6 +160,14 @@ describe('healthRoute', () => { summarizeMonitoringStats(warnWorkloadStat, getTaskManagerConfig({})) ), }); + expect(logHealthMetrics.mock.calls[2][0]).toMatchObject({ + id, + timestamp: expect.any(String), + status: expect.any(String), + ...ignoreCapacityEstimation( + summarizeMonitoringStats(warnEphemeralStat, getTaskManagerConfig({})) + ), + }); }); it(`logs at an error level if the status is error`, async () => { @@ -168,6 +180,7 @@ describe('healthRoute', () => { const errorRuntimeStat = mockHealthStats(); const errorConfigurationStat = mockHealthStats(); const errorWorkloadStat = mockHealthStats(); + const errorEphemeralStat = mockHealthStats(); const stats$ = new Subject(); @@ -192,8 +205,10 @@ describe('healthRoute', () => { stats$.next(errorConfigurationStat); await sleep(1001); stats$.next(errorWorkloadStat); + await sleep(1001); + stats$.next(errorEphemeralStat); - expect(logHealthMetrics).toBeCalledTimes(3); + expect(logHealthMetrics).toBeCalledTimes(4); expect(logHealthMetrics.mock.calls[0][0]).toMatchObject({ id, timestamp: expect.any(String), @@ -218,6 +233,14 @@ describe('healthRoute', () => { summarizeMonitoringStats(errorWorkloadStat, getTaskManagerConfig({})) ), }); + expect(logHealthMetrics.mock.calls[2][0]).toMatchObject({ + id, + timestamp: expect.any(String), + status: expect.any(String), + ...ignoreCapacityEstimation( + summarizeMonitoringStats(errorEphemeralStat, getTaskManagerConfig({})) + ), + }); }); it('returns a error status if the overall stats have not been updated within the required hot freshness', async () => { @@ -225,7 +248,7 @@ describe('healthRoute', () => { const stats$ = new Subject(); - const serviceStatus$ = healthRoute( + const { serviceStatus$ } = healthRoute( router, stats$, loggingSystemMock.create().get(), @@ -264,6 +287,9 @@ describe('healthRoute', () => { workload: { timestamp: expect.any(String), }, + ephemeral: { + timestamp: expect.any(String), + }, runtime: { timestamp: expect.any(String), value: { @@ -335,6 +361,9 @@ describe('healthRoute', () => { workload: { timestamp: expect.any(String), }, + ephemeral: { + timestamp: expect.any(String), + }, runtime: { timestamp: expect.any(String), value: { @@ -403,6 +432,9 @@ describe('healthRoute', () => { workload: { timestamp: expect.any(String), }, + ephemeral: { + timestamp: expect.any(String), + }, runtime: { timestamp: expect.any(String), value: { @@ -488,14 +520,25 @@ function mockHealthStats(overrides = {}) { duration: [500, 400, 3000], claim_conflicts: [0, 100, 75], claim_mismatches: [0, 100, 75], + claim_duration: [0, 100, 75], result_frequency_percent_as_number: [ - 'NoTasksClaimed', - 'NoTasksClaimed', - 'NoTasksClaimed', + FillPoolResult.NoTasksClaimed, + FillPoolResult.NoTasksClaimed, + FillPoolResult.NoTasksClaimed, ], + persistence: [], }, }, }, + ephemeral: { + timestamp: new Date().toISOString(), + value: { + load: [], + executionsPerCycle: [], + queuedTasks: [], + delay: [], + }, + }, }, }; return (merge(stub, overrides) as unknown) as MonitoringStats; diff --git a/x-pack/plugins/task_manager/server/routes/health.ts b/x-pack/plugins/task_manager/server/routes/health.ts index b5d8a23ba55575..fe58ee3490affa 100644 --- a/x-pack/plugins/task_manager/server/routes/health.ts +++ b/x-pack/plugins/task_manager/server/routes/health.ts @@ -53,7 +53,10 @@ export function healthRoute( logger: Logger, taskManagerId: string, config: TaskManagerConfig -): Observable { +): { + serviceStatus$: Observable; + monitoredHealth$: Observable; +} { // if "hot" health stats are any more stale than monitored_stats_required_freshness (pollInterval +1s buffer by default) // consider the system unhealthy const requiredHotStatsFreshness: number = config.monitored_stats_required_freshness; @@ -67,6 +70,7 @@ export function healthRoute( } const serviceStatus$: Subject = new Subject(); + const monitoredHealth$: Subject = new Subject(); /* keep track of last health summary, as we'll return that to the next call to _health */ let lastMonitoredStats: MonitoringStats | null = null; @@ -84,6 +88,7 @@ export function healthRoute( ) .subscribe(([monitoredHealth, serviceStatus]) => { serviceStatus$.next(serviceStatus); + monitoredHealth$.next(monitoredHealth); logHealthMetrics(monitoredHealth, logger, config); }); @@ -104,7 +109,7 @@ export function healthRoute( }); } ); - return serviceStatus$; + return { serviceStatus$, monitoredHealth$ }; } export function withServiceStatus( diff --git a/x-pack/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts index 8f515e1951ef5f..2452e3e6f4920d 100644 --- a/x-pack/plugins/task_manager/server/task.ts +++ b/x-pack/plugins/task_manager/server/task.ts @@ -363,6 +363,13 @@ export interface ConcreteTaskInstance extends TaskInstance { ownerId: string | null; } +/** + * A task instance that has an id and is ready for storage. + */ +export type EphemeralTask = Pick; +export type EphemeralTaskInstance = EphemeralTask & + Pick; + export type SerializedConcreteTaskInstance = Omit< ConcreteTaskInstance, 'state' | 'params' | 'scheduledAt' | 'startedAt' | 'retryAt' | 'runAt' diff --git a/x-pack/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts index aecf7c9a2b7e89..7c7845569a10b5 100644 --- a/x-pack/plugins/task_manager/server/task_events.ts +++ b/x-pack/plugins/task_manager/server/task_events.ts @@ -13,6 +13,13 @@ import { Result, Err } from './lib/result_type'; import { ClaimAndFillPoolResult } from './lib/fill_pool'; import { PollingError } from './polling'; import { TaskRunResult } from './task_running'; +import { EphemeralTaskInstanceRequest } from './ephemeral_task_lifecycle'; + +export enum TaskPersistence { + Recurring = 'recurring', + NonRecurring = 'non_recurring', + Ephemeral = 'ephemeral', +} export enum TaskEventType { TASK_CLAIM = 'TASK_CLAIM', @@ -21,6 +28,7 @@ export enum TaskEventType { TASK_RUN_REQUEST = 'TASK_RUN_REQUEST', TASK_POLLING_CYCLE = 'TASK_POLLING_CYCLE', TASK_MANAGER_STAT = 'TASK_MANAGER_STAT', + EPHEMERAL_TASK_DELAYED_DUE_TO_CAPACITY = 'EPHEMERAL_TASK_DELAYED_DUE_TO_CAPACITY', } export enum TaskClaimErrorType { @@ -48,6 +56,7 @@ export interface TaskEvent { } export interface RanTask { task: ConcreteTaskInstance; + persistence: TaskPersistence; result: TaskRunResult; } export type ErroredTask = RanTask & { @@ -62,9 +71,15 @@ export type TaskMarkRunning = TaskEvent; export type TaskRun = TaskEvent; export type TaskClaim = TaskEvent; export type TaskRunRequest = TaskEvent; +export type EphemeralTaskRejectedDueToCapacity = TaskEvent; export type TaskPollingCycle = TaskEvent>; -export type TaskManagerStats = 'load' | 'pollingDelay' | 'claimDuration'; +export type TaskManagerStats = + | 'load' + | 'pollingDelay' + | 'claimDuration' + | 'queuedEphemeralTasks' + | 'ephemeralTaskDelay'; export type TaskManagerStat = TaskEvent; export type OkResultOf = EventType extends TaskEvent @@ -149,6 +164,19 @@ export function asTaskManagerStatEvent( }; } +export function asEphemeralTaskRejectedDueToCapacityEvent( + id: string, + event: Result, + timing?: TaskTiming +): EphemeralTaskRejectedDueToCapacity { + return { + id, + type: TaskEventType.EPHEMERAL_TASK_DELAYED_DUE_TO_CAPACITY, + event, + timing, + }; +} + export function isTaskMarkRunningEvent( taskEvent: TaskEvent ): taskEvent is TaskMarkRunning { @@ -175,3 +203,8 @@ export function isTaskManagerStatEvent( ): taskEvent is TaskManagerStat { return taskEvent.type === TaskEventType.TASK_MANAGER_STAT; } +export function isEphemeralTaskRejectedDueToCapacityEvent( + taskEvent: TaskEvent +): taskEvent is EphemeralTaskRejectedDueToCapacity { + return taskEvent.type === TaskEventType.EPHEMERAL_TASK_DELAYED_DUE_TO_CAPACITY; +} diff --git a/x-pack/plugins/task_manager/server/task_pool.mock.ts b/x-pack/plugins/task_manager/server/task_pool.mock.ts new file mode 100644 index 00000000000000..de82d5872d5dde --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_pool.mock.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { TaskPool } from './task_pool'; + +const defaultGetCapacityOverride: () => Partial<{ + load: number; + occupiedWorkers: number; + workerLoad: number; + max: number; + availableWorkers: number; +}> = () => ({ + load: 0, + occupiedWorkers: 0, + workerLoad: 0, + max: 10, + availableWorkers: 10, +}); + +const createTaskPoolMock = (getCapacityOverride = defaultGetCapacityOverride) => { + return ({ + get load() { + return getCapacityOverride().load ?? 0; + }, + get occupiedWorkers() { + return getCapacityOverride().occupiedWorkers ?? 0; + }, + get workerLoad() { + return getCapacityOverride().workerLoad ?? 0; + }, + get max() { + return getCapacityOverride().max ?? 10; + }, + get availableWorkers() { + return getCapacityOverride().availableWorkers ?? 10; + }, + getOccupiedWorkersByType: jest.fn(), + run: jest.fn(), + cancelRunningTasks: jest.fn(), + } as unknown) as jest.Mocked; +}; + +export const TaskPoolMock = { + create: createTaskPoolMock, +}; diff --git a/x-pack/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts index 14c0c4581a15bb..d394214e6c7780 100644 --- a/x-pack/plugins/task_manager/server/task_pool.ts +++ b/x-pack/plugins/task_manager/server/task_pool.ts @@ -16,8 +16,7 @@ import { padStart } from 'lodash'; import { Logger } from '../../../../src/core/server'; import { TaskRunner } from './task_running'; import { isTaskSavedObjectNotFoundError } from './lib/is_task_not_found_error'; -import { TaskManagerStat, asTaskManagerStatEvent } from './task_events'; -import { asOk } from './lib/result_type'; +import { TaskManagerStat } from './task_events'; interface Opts { maxWorkers$: Observable; @@ -84,10 +83,6 @@ export class TaskPool { * Gets how many workers are currently available. */ public get availableWorkers() { - // emit load whenever we check how many available workers there are - // this should happen less often than the actual changes to the worker queue - // so is lighter than emitting the load every time we add/remove a task from the queue - this.load$.next(asTaskManagerStatEvent('load', asOk(this.workerLoad))); // cancel expired task whenever a call is made to check for capacity // this ensures that we don't end up with a queue of hung tasks causing both // the poller and the pool from hanging due to lack of capacity @@ -174,7 +169,9 @@ export class TaskPool { this.logger.warn(errorLogLine); } }) - .then(() => this.tasksInPool.delete(taskRunner.id)); + .then(() => { + this.tasksInPool.delete(taskRunner.id); + }); } private handleFailureOfMarkAsRunning(task: TaskRunner, err: Error) { diff --git a/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts b/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts new file mode 100644 index 00000000000000..bc1ff0541fdffc --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_running/ephemeral_task_runner.ts @@ -0,0 +1,337 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * This module contains the core logic for running an individual task. + * It handles the full lifecycle of a task run, including error handling, + * rescheduling, middleware application, etc. + */ + +import apm from 'elastic-apm-node'; +import { withSpan } from '@kbn/apm-utils'; +import { identity } from 'lodash'; +import { Logger } from '../../../../../src/core/server'; + +import { Middleware } from '../lib/middleware'; +import { asOk, asErr, eitherAsync, Result } from '../lib/result_type'; +import { + TaskRun, + TaskMarkRunning, + asTaskRunEvent, + asTaskMarkRunningEvent, + startTaskTimer, + TaskTiming, + TaskPersistence, +} from '../task_events'; +import { intervalFromDate } from '../lib/intervals'; +import { + CancellableTask, + ConcreteTaskInstance, + isFailedRunResult, + SuccessfulRunResult, + FailedRunResult, + TaskStatus, + EphemeralTaskInstance, +} from '../task'; +import { TaskTypeDictionary } from '../task_type_dictionary'; +import { + asPending, + asReadyToRun, + EMPTY_RUN_RESULT, + isPending, + isReadyToRun, + TaskRunner, + TaskRunningInstance, + TaskRunResult, +} from './task_runner'; + +type Opts = { + logger: Logger; + definitions: TaskTypeDictionary; + instance: EphemeralTaskInstance; + onTaskEvent?: (event: TaskRun | TaskMarkRunning) => void; +} & Pick; + +// ephemeral tasks cannot be rescheduled or scheduled to run again in the future +type EphemeralSuccessfulRunResult = Omit; +type EphemeralFailedRunResult = Omit; + +/** + * + * @export + * @class EphemeralTaskManagerRunner + * @implements {TaskRunner} + */ +export class EphemeralTaskManagerRunner implements TaskRunner { + private task?: CancellableTask; + private instance: TaskRunningInstance; + private definitions: TaskTypeDictionary; + private logger: Logger; + private beforeRun: Middleware['beforeRun']; + private beforeMarkRunning: Middleware['beforeMarkRunning']; + private onTaskEvent: (event: TaskRun | TaskMarkRunning) => void; + + /** + * Creates an instance of EphemeralTaskManagerRunner. + * @param {Opts} opts + * @prop {Logger} logger - The task manager logger + * @prop {TaskDefinition} definition - The definition of the task being run + * @prop {EphemeralTaskInstance} instance - The record describing this particular task instance + * @prop {BeforeRunFunction} beforeRun - A function that adjusts the run context prior to running the task + * @memberof TaskManagerRunner + */ + constructor({ + instance, + definitions, + logger, + beforeRun, + beforeMarkRunning, + onTaskEvent = identity, + }: Opts) { + this.instance = asPending(asConcreteInstance(sanitizeInstance(instance))); + this.definitions = definitions; + this.logger = logger; + this.beforeRun = beforeRun; + this.beforeMarkRunning = beforeMarkRunning; + this.onTaskEvent = onTaskEvent; + } + + /** + * Gets the id of this task instance. + */ + public get id() { + return this.instance.task.id; + } + + /** + * Gets the task type of this task instance. + */ + public get taskType() { + return this.instance.task.taskType; + } + + /** + * Get the stage this TaskRunner is at + */ + public get stage() { + return this.instance.stage; + } + + /** + * Gets the task defintion from the dictionary. + */ + public get definition() { + return this.definitions.get(this.taskType); + } + + /** + * Gets the time at which this task will expire. + */ + public get expiration() { + return intervalFromDate( + // if the task is running, use it's started at, otherwise use the timestamp at + // which it was last updated + // this allows us to catch tasks that remain in Pending/Finalizing without being + // cleaned up + isReadyToRun(this.instance) ? this.instance.task.startedAt : this.instance.timestamp, + this.definition.timeout + )!; + } + + /** + * Gets the duration of the current task run + */ + public get startedAt() { + return this.instance.task.startedAt; + } + + /** + * Gets whether or not this task has run longer than its expiration setting allows. + */ + public get isExpired() { + return this.expiration < new Date(); + } + + public get isEphemeral() { + return true; + } + + /** + * Returns a log-friendly representation of this task. + */ + public toString() { + return `${this.taskType} "${this.id}" (Ephemeral)`; + } + + /** + * Runs the task, handling the task result, errors, etc, rescheduling if need + * be. NOTE: the time of applying the middleware's beforeRun is incorporated + * into the total timeout time the task in configured with. We may decide to + * start the timer after beforeRun resolves + * + * @returns {Promise>} + */ + public async run(): Promise> { + if (!isReadyToRun(this.instance)) { + throw new Error( + `Running ephemeral task ${this} failed as it ${ + isPending(this.instance) ? `isn't ready to be ran` : `has already been ran` + }` + ); + } + this.logger.debug(`Running ephemeral task ${this}`); + const apmTrans = apm.startTransaction(this.taskType, 'taskManager ephemeral run', { + childOf: this.instance.task.traceparent, + }); + const modifiedContext = await this.beforeRun({ + taskInstance: asConcreteInstance(this.instance.task), + }); + const stopTaskTimer = startTaskTimer(); + try { + this.task = this.definition.createTaskRunner(modifiedContext); + const result = await withSpan({ name: 'ephemeral run', type: 'task manager' }, () => + this.task!.run() + ); + const validatedResult = this.validateResult(result); + const processedResult = await withSpan( + { name: 'process ephemeral result', type: 'task manager' }, + () => this.processResult(validatedResult, stopTaskTimer()) + ); + if (apmTrans) apmTrans.end('success'); + return processedResult; + } catch (err) { + this.logger.error(`Task ${this} failed: ${err}`); + // in error scenario, we can not get the RunResult + const processedResult = await withSpan( + { name: 'process ephemeral result', type: 'task manager' }, + () => + this.processResult( + asErr({ error: err, state: modifiedContext.taskInstance.state }), + stopTaskTimer() + ) + ); + if (apmTrans) apmTrans.end('failure'); + return processedResult; + } + } + + /** + * Noop for Ephemeral tasks + * + * @returns {Promise} + */ + public async markTaskAsRunning(): Promise { + if (!isPending(this.instance)) { + throw new Error( + `Marking ephemeral task ${this} as running has failed as it ${ + isReadyToRun(this.instance) ? `is already running` : `has already been ran` + }` + ); + } + + const apmTrans = apm.startTransaction('taskManager', 'taskManager markTaskAsRunning'); + + const now = new Date(); + try { + const { taskInstance } = await this.beforeMarkRunning({ + taskInstance: asConcreteInstance(this.instance.task), + }); + + this.instance = asReadyToRun({ + ...taskInstance, + status: TaskStatus.Running, + startedAt: now, + attempts: taskInstance.attempts + 1, + retryAt: null, + }); + + if (apmTrans) apmTrans.end('success'); + this.onTaskEvent(asTaskMarkRunningEvent(this.id, asOk(this.instance.task))); + return true; + } catch (error) { + if (apmTrans) apmTrans.end('failure'); + this.onTaskEvent(asTaskMarkRunningEvent(this.id, asErr(error))); + } + return false; + } + + /** + * Attempts to cancel the task. + * + * @returns {Promise} + */ + public async cancel() { + const { task } = this; + if (task?.cancel) { + this.task = undefined; + return task.cancel(); + } + + this.logger.debug(`The ephemral task ${this} is not cancellable.`); + } + + private validateResult( + result?: SuccessfulRunResult | FailedRunResult | void + ): Result { + return isFailedRunResult(result) + ? asErr({ ...result, error: result.error }) + : asOk(result || EMPTY_RUN_RESULT); + } + + private async processResult( + result: Result, + taskTiming: TaskTiming + ): Promise> { + await eitherAsync( + result, + async ({ state }: EphemeralSuccessfulRunResult) => { + this.onTaskEvent( + asTaskRunEvent( + this.id, + asOk({ + task: { ...this.instance.task, state }, + persistence: TaskPersistence.Ephemeral, + result: TaskRunResult.Success, + }), + taskTiming + ) + ); + }, + async ({ error, state }: EphemeralFailedRunResult) => { + this.onTaskEvent( + asTaskRunEvent( + this.id, + asErr({ + task: { ...this.instance.task, state }, + persistence: TaskPersistence.Ephemeral, + result: TaskRunResult.Failed, + error, + }), + taskTiming + ) + ); + } + ); + return result; + } +} + +function sanitizeInstance(instance: EphemeralTaskInstance): EphemeralTaskInstance { + return { + ...instance, + params: instance.params || {}, + state: instance.state || {}, + }; +} + +function asConcreteInstance(instance: EphemeralTaskInstance): ConcreteTaskInstance { + return { + ...instance, + attempts: 0, + retryAt: null, + }; +} diff --git a/x-pack/plugins/task_manager/server/task_running/errors.ts b/x-pack/plugins/task_manager/server/task_running/errors.ts index 8b01a5fb266c83..43466fae0e2ebc 100644 --- a/x-pack/plugins/task_manager/server/task_running/errors.ts +++ b/x-pack/plugins/task_manager/server/task_running/errors.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { EphemeralTask } from '../task'; // Unrecoverable const CODE_UNRECOVERABLE = 'TaskManager/unrecoverable'; @@ -14,6 +15,19 @@ export interface DecoratedError extends Error { [code]?: string; } +export class EphemeralTaskRejectedDueToCapacityError extends Error { + private _task: EphemeralTask; + + constructor(message: string, task: EphemeralTask) { + super(message); + this._task = task; + } + + public get task() { + return this._task; + } +} + function isTaskManagerError(error: unknown): error is DecoratedError { return Boolean(error && (error as DecoratedError)[code]); } @@ -26,3 +40,9 @@ export function throwUnrecoverableError(error: Error) { (error as DecoratedError)[code] = CODE_UNRECOVERABLE; throw error; } + +export function isEphemeralTaskRejectedDueToCapacityError( + error: Error | EphemeralTaskRejectedDueToCapacityError +) { + return Boolean(error && error instanceof EphemeralTaskRejectedDueToCapacityError); +} diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts index d5a86b532b0ae9..e54962c7c8857f 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.test.ts @@ -10,7 +10,13 @@ import sinon from 'sinon'; import { secondsFromNow } from '../lib/intervals'; import { asOk, asErr } from '../lib/result_type'; import { TaskManagerRunner, TaskRunningStage, TaskRunResult } from '../task_running'; -import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent, TaskRun } from '../task_events'; +import { + TaskEvent, + asTaskRunEvent, + asTaskMarkRunningEvent, + TaskRun, + TaskPersistence, +} from '../task_events'; import { ConcreteTaskInstance, TaskStatus } from '../task'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import moment from 'moment'; @@ -854,7 +860,12 @@ describe('TaskManagerRunner', () => { const onTaskEvent = jest.fn(); const { runner, store, instance: originalInstance } = await readyToRunStageSetup({ onTaskEvent, - instance: { id, status: TaskStatus.Running, startedAt: new Date() }, + instance: { + id, + schedule: { interval: '20m' }, + status: TaskStatus.Running, + startedAt: new Date(), + }, definitions: { bar: { title: 'Bar!', @@ -878,6 +889,7 @@ describe('TaskManagerRunner', () => { id, asErr({ error, + persistence: TaskPersistence.Recurring, task: originalInstance, result: TaskRunResult.Failed, }) @@ -1209,7 +1221,16 @@ describe('TaskManagerRunner', () => { await runner.run(); expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + withAnyTiming( + asTaskRunEvent( + id, + asOk({ + task: instance, + persistence: TaskPersistence.NonRecurring, + result: TaskRunResult.Success, + }) + ) + ) ); }); @@ -1238,7 +1259,16 @@ describe('TaskManagerRunner', () => { await runner.run(); expect(onTaskEvent).toHaveBeenCalledWith( - withAnyTiming(asTaskRunEvent(id, asOk({ task: instance, result: TaskRunResult.Success }))) + withAnyTiming( + asTaskRunEvent( + id, + asOk({ + task: instance, + persistence: TaskPersistence.Recurring, + result: TaskRunResult.Success, + }) + ) + ) ); }); @@ -1268,7 +1298,12 @@ describe('TaskManagerRunner', () => { withAnyTiming( asTaskRunEvent( id, - asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + asErr({ + error, + task: instance, + persistence: TaskPersistence.NonRecurring, + result: TaskRunResult.RetryScheduled, + }) ) ) ); @@ -1304,7 +1339,12 @@ describe('TaskManagerRunner', () => { withAnyTiming( asTaskRunEvent( id, - asErr({ error, task: instance, result: TaskRunResult.RetryScheduled }) + asErr({ + error, + task: instance, + persistence: TaskPersistence.Recurring, + result: TaskRunResult.RetryScheduled, + }) ) ) ); @@ -1346,6 +1386,7 @@ describe('TaskManagerRunner', () => { asErr({ error, task: originalInstance, + persistence: TaskPersistence.NonRecurring, result: TaskRunResult.Failed, }) ) diff --git a/x-pack/plugins/task_manager/server/task_running/task_runner.ts b/x-pack/plugins/task_manager/server/task_running/task_runner.ts index fc88a66329170f..97b40a75a59c45 100644 --- a/x-pack/plugins/task_manager/server/task_running/task_runner.ts +++ b/x-pack/plugins/task_manager/server/task_running/task_runner.ts @@ -36,6 +36,7 @@ import { asTaskMarkRunningEvent, startTaskTimer, TaskTiming, + TaskPersistence, } from '../task_events'; import { intervalFromDate, maxIntervalFromDate } from '../lib/intervals'; import { @@ -53,7 +54,7 @@ import { TaskTypeDictionary } from '../task_type_dictionary'; import { isUnrecoverableError } from './errors'; const defaultBackoffPerFailure = 5 * 60 * 1000; -const EMPTY_RUN_RESULT: SuccessfulRunResult = { state: {} }; +export const EMPTY_RUN_RESULT: SuccessfulRunResult = { state: {} }; export interface TaskRunner { isExpired: boolean; @@ -65,6 +66,7 @@ export interface TaskRunner { run: () => Promise>; id: string; stage: string; + isEphemeral?: boolean; toString: () => string; } @@ -105,14 +107,17 @@ export enum TaskRunResult { } // A ConcreteTaskInstance which we *know* has a `startedAt` Date on it -type ConcreteTaskInstanceWithStartedAt = ConcreteTaskInstance & { startedAt: Date }; +export type ConcreteTaskInstanceWithStartedAt = ConcreteTaskInstance & { startedAt: Date }; // The three possible stages for a Task Runner - Pending -> ReadyToRun -> Ran -type PendingTask = TaskRunning; -type ReadyToRunTask = TaskRunning; -type RanTask = TaskRunning; +export type PendingTask = TaskRunning; +export type ReadyToRunTask = TaskRunning< + TaskRunningStage.READY_TO_RUN, + ConcreteTaskInstanceWithStartedAt +>; +export type RanTask = TaskRunning; -type TaskRunningInstance = PendingTask | ReadyToRunTask | RanTask; +export type TaskRunningInstance = PendingTask | ReadyToRunTask | RanTask; /** * Runs a background task, ensures that errors are properly handled, @@ -528,6 +533,10 @@ export class TaskManagerRunner implements TaskRunner { this.id, asOk({ task, + persistence: + schedule || task.schedule + ? TaskPersistence.Recurring + : TaskPersistence.NonRecurring, result: await (runAt || schedule || task.schedule ? this.processResultForRecurringTask(result) : this.processResultWhenDone()), @@ -540,7 +549,12 @@ export class TaskManagerRunner implements TaskRunner { this.onTaskEvent( asTaskRunEvent( this.id, - asErr({ task, result: await this.processResultForRecurringTask(result), error }), + asErr({ + task, + persistence: task.schedule ? TaskPersistence.Recurring : TaskPersistence.NonRecurring, + result: await this.processResultForRecurringTask(result), + error, + }), taskTiming ) ); @@ -602,20 +616,20 @@ function performanceStopMarkingTaskAsRunning() { // in a specific place in the code might be type InstanceOf = T extends TaskRunning ? I : never; -function isPending(taskRunning: TaskRunningInstance): taskRunning is PendingTask { +export function isPending(taskRunning: TaskRunningInstance): taskRunning is PendingTask { return taskRunning.stage === TaskRunningStage.PENDING; } -function asPending(task: InstanceOf): PendingTask { +export function asPending(task: InstanceOf): PendingTask { return { timestamp: new Date(), stage: TaskRunningStage.PENDING, task, }; } -function isReadyToRun(taskRunning: TaskRunningInstance): taskRunning is ReadyToRunTask { +export function isReadyToRun(taskRunning: TaskRunningInstance): taskRunning is ReadyToRunTask { return taskRunning.stage === TaskRunningStage.READY_TO_RUN; } -function asReadyToRun( +export function asReadyToRun( task: InstanceOf ): ReadyToRunTask { return { @@ -624,7 +638,7 @@ function asReadyToRun( task, }; } -function asRan(task: InstanceOf): RanTask { +export function asRan(task: InstanceOf): RanTask { return { timestamp: new Date(), stage: TaskRunningStage.RAN, diff --git a/x-pack/plugins/task_manager/server/task_scheduling.mock.ts b/x-pack/plugins/task_manager/server/task_scheduling.mock.ts index 02b58eafa5fe52..60742e83664b40 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.mock.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.mock.ts @@ -12,6 +12,7 @@ const createTaskSchedulingMock = () => { ensureScheduled: jest.fn(), schedule: jest.fn(), runNow: jest.fn(), + ephemeralRunNow: jest.fn(), } as unknown) as jest.Mocked; }; diff --git a/x-pack/plugins/task_manager/server/task_scheduling.test.ts b/x-pack/plugins/task_manager/server/task_scheduling.test.ts index 3445bd18de1025..41a172bfb2f8ed 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.test.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.test.ts @@ -15,6 +15,7 @@ import { asTaskClaimEvent, asTaskRunRequestEvent, TaskClaimErrorType, + TaskPersistence, } from './task_events'; import { TaskLifecycleEvent } from './polling_lifecycle'; import { taskPollingLifecycleMock } from './polling_lifecycle.mock'; @@ -26,6 +27,11 @@ import { taskStoreMock } from './task_store.mock'; import { TaskRunResult } from './task_running'; import { mockLogger } from './test_utils'; import { TaskTypeDictionary } from './task_type_dictionary'; +import { ephemeralTaskLifecycleMock } from './ephemeral_task_lifecycle.mock'; + +jest.mock('uuid', () => ({ + v4: () => 'v4uuid', +})); jest.mock('elastic-apm-node', () => ({ currentTraceparent: 'parent', @@ -41,6 +47,8 @@ describe('TaskScheduling', () => { logger: mockLogger(), middleware: createInitialMiddleware(), definitions, + ephemeralTaskLifecycle: ephemeralTaskLifecycleMock.create({}), + taskManagerId: '', }; definitions.registerTaskDefinitions({ @@ -137,7 +145,12 @@ describe('TaskScheduling', () => { const result = taskScheduling.runNow(id); const task = mockTask({ id }); - events$.next(asTaskRunEvent(id, asOk({ task, result: TaskRunResult.Success }))); + events$.next( + asTaskRunEvent( + id, + asOk({ task, result: TaskRunResult.Success, persistence: TaskPersistence.Recurring }) + ) + ); return expect(result).resolves.toEqual({ id }); }); @@ -163,6 +176,7 @@ describe('TaskScheduling', () => { task, error: new Error('some thing gone wrong'), result: TaskRunResult.Failed, + persistence: TaskPersistence.Recurring, }) ) ); @@ -393,7 +407,14 @@ describe('TaskScheduling', () => { events$.next(asTaskClaimEvent(id, asOk(task))); events$.next(asTaskClaimEvent(differentTask, asOk(otherTask))); events$.next( - asTaskRunEvent(differentTask, asOk({ task: otherTask, result: TaskRunResult.Success })) + asTaskRunEvent( + differentTask, + asOk({ + task: otherTask, + result: TaskRunResult.Success, + persistence: TaskPersistence.Recurring, + }) + ) ); events$.next( @@ -403,6 +424,7 @@ describe('TaskScheduling', () => { task, error: new Error('some thing gone wrong'), result: TaskRunResult.Failed, + persistence: TaskPersistence.Recurring, }) ) ); @@ -411,6 +433,97 @@ describe('TaskScheduling', () => { `[Error: Failed to run task "01ddff11-e88a-4d13-bc4e-256164e755e2": Error: some thing gone wrong]` ); }); + + test('runs a task ephemerally', async () => { + const ephemeralEvents$ = new Subject(); + const ephemeralTask = mockTask({ + state: { + foo: 'bar', + }, + }); + const customEphemeralTaskLifecycleMock = ephemeralTaskLifecycleMock.create({ + events$: ephemeralEvents$, + }); + + customEphemeralTaskLifecycleMock.attemptToRun.mockImplementation((value) => { + return { + tag: 'ok', + value, + }; + }); + + const middleware = createInitialMiddleware(); + middleware.beforeSave = jest.fn().mockImplementation(async () => { + return { taskInstance: ephemeralTask }; + }); + const taskScheduling = new TaskScheduling({ + ...taskSchedulingOpts, + middleware, + ephemeralTaskLifecycle: customEphemeralTaskLifecycleMock, + }); + + const result = taskScheduling.ephemeralRunNow(ephemeralTask); + ephemeralEvents$.next( + asTaskRunEvent( + 'v4uuid', + asOk({ + task: { + ...ephemeralTask, + id: 'v4uuid', + }, + result: TaskRunResult.Success, + persistence: TaskPersistence.Ephemeral, + }) + ) + ); + + expect(result).resolves.toEqual({ id: 'v4uuid', state: { foo: 'bar' } }); + }); + + test('rejects ephemeral task if lifecycle returns an error', async () => { + const ephemeralEvents$ = new Subject(); + const ephemeralTask = mockTask({ + state: { + foo: 'bar', + }, + }); + const customEphemeralTaskLifecycleMock = ephemeralTaskLifecycleMock.create({ + events$: ephemeralEvents$, + }); + + customEphemeralTaskLifecycleMock.attemptToRun.mockImplementation((value) => { + return asErr(value); + }); + + const middleware = createInitialMiddleware(); + middleware.beforeSave = jest.fn().mockImplementation(async () => { + return { taskInstance: ephemeralTask }; + }); + const taskScheduling = new TaskScheduling({ + ...taskSchedulingOpts, + middleware, + ephemeralTaskLifecycle: customEphemeralTaskLifecycleMock, + }); + + const result = taskScheduling.ephemeralRunNow(ephemeralTask); + ephemeralEvents$.next( + asTaskRunEvent( + 'v4uuid', + asOk({ + task: { + ...ephemeralTask, + id: 'v4uuid', + }, + result: TaskRunResult.Failed, + persistence: TaskPersistence.Ephemeral, + }) + ) + ); + + expect(result).rejects.toMatchInlineSnapshot( + `[Error: Ephemeral Task of type foo was rejected]` + ); + }); }); }); diff --git a/x-pack/plugins/task_manager/server/task_scheduling.ts b/x-pack/plugins/task_manager/server/task_scheduling.ts index 153c16f5c4bf78..88176b25680ca1 100644 --- a/x-pack/plugins/task_manager/server/task_scheduling.ts +++ b/x-pack/plugins/task_manager/server/task_scheduling.ts @@ -5,14 +5,17 @@ * 2.0. */ -import { filter } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { pipe } from 'fp-ts/lib/pipeable'; import { Option, map as mapOptional, getOrElse, isSome } from 'fp-ts/lib/Option'; +import uuid from 'uuid'; +import { pick } from 'lodash'; +import { merge, Subject } from 'rxjs'; import agent from 'elastic-apm-node'; import { Logger } from '../../../../src/core/server'; -import { asOk, either, map, mapErr, promiseResult } from './lib/result_type'; +import { asOk, either, map, mapErr, promiseResult, isErr } from './lib/result_type'; import { isTaskRunEvent, isTaskClaimEvent, @@ -32,11 +35,14 @@ import { TaskLifecycle, TaskLifecycleResult, TaskStatus, + EphemeralTask, } from './task'; import { TaskStore } from './task_store'; import { ensureDeprecatedFieldsAreCorrected } from './lib/correct_deprecated_fields'; import { TaskLifecycleEvent, TaskPollingLifecycle } from './polling_lifecycle'; import { TaskTypeDictionary } from './task_type_dictionary'; +import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle'; +import { EphemeralTaskRejectedDueToCapacityError } from './task_running'; const VERSION_CONFLICT_STATUS = 409; @@ -44,20 +50,25 @@ export interface TaskSchedulingOpts { logger: Logger; taskStore: TaskStore; taskPollingLifecycle: TaskPollingLifecycle; + ephemeralTaskLifecycle: EphemeralTaskLifecycle; middleware: Middleware; definitions: TaskTypeDictionary; + taskManagerId: string; } -interface RunNowResult { - id: string; +export interface RunNowResult { + id: ConcreteTaskInstance['id']; + state?: ConcreteTaskInstance['state']; } export class TaskScheduling { private store: TaskStore; private taskPollingLifecycle: TaskPollingLifecycle; + private ephemeralTaskLifecycle: EphemeralTaskLifecycle; private logger: Logger; private middleware: Middleware; private definitions: TaskTypeDictionary; + private taskManagerId: string; /** * Initializes the task manager, preventing any further addition of middleware, @@ -68,8 +79,10 @@ export class TaskScheduling { this.logger = opts.logger; this.middleware = opts.middleware; this.taskPollingLifecycle = opts.taskPollingLifecycle; + this.ephemeralTaskLifecycle = opts.ephemeralTaskLifecycle; this.store = opts.taskStore; this.definitions = opts.definitions; + this.taskManagerId = opts.taskManagerId; } /** @@ -100,11 +113,67 @@ export class TaskScheduling { */ public async runNow(taskId: string): Promise { return new Promise(async (resolve, reject) => { - this.awaitTaskRunResult(taskId).then(resolve).catch(reject); + this.awaitTaskRunResult(taskId) + // don't expose state on runNow + .then(({ id }) => resolve({ id })) + .catch(reject); this.taskPollingLifecycle.attemptToRun(taskId); }); } + /** + * Run an ad-hoc task in memory without persisting it into ES or distributing the load across the cluster. + * + * @param task - The ephemeral task being queued. + * @returns {Promise} + */ + public async ephemeralRunNow( + task: EphemeralTask, + options?: Record + ): Promise { + const id = uuid.v4(); + const { taskInstance: modifiedTask } = await this.middleware.beforeSave({ + ...options, + taskInstance: task, + }); + return new Promise(async (resolve, reject) => { + // The actual promise returned from this function is resolved after the awaitTaskRunResult promise resolves. + // However, we do not wait to await this promise, as we want later execution to happen in parallel. + // The awaitTaskRunResult promise is resolved once the ephemeral task is successfully executed (technically, when a TaskEventType.TASK_RUN is emitted with the same id). + // However, the ephemeral task won't even get into the queue until the subsequent this.ephemeralTaskLifecycle.attemptToRun is called (which puts it in the queue). + + // The reason for all this confusion? Timing. + + // In the this.ephemeralTaskLifecycle.attemptToRun, it's possible that the ephemeral task is put into the queue and processed before this function call returns anything. + // If that happens, putting the awaitTaskRunResult after would just hang because the task already completed. We need to listen for the completion before we add it to the queue to avoid this possibility. + const { cancel, resolveOnCancel } = cancellablePromise(); + this.awaitTaskRunResult(id, resolveOnCancel) + .then((arg: RunNowResult) => { + resolve(arg); + }) + .catch((err: Error) => { + reject(err); + }); + const attemptToRunResult = this.ephemeralTaskLifecycle.attemptToRun({ + id, + scheduledAt: new Date(), + runAt: new Date(), + status: TaskStatus.Idle, + ownerId: this.taskManagerId, + ...modifiedTask, + }); + if (isErr(attemptToRunResult)) { + cancel(); + reject( + new EphemeralTaskRejectedDueToCapacityError( + `Ephemeral Task of type ${task.taskType} was rejected`, + task + ) + ); + } + }); + } + /** * Schedules a task with an Id * @@ -125,10 +194,13 @@ export class TaskScheduling { } } - private async awaitTaskRunResult(taskId: string): Promise { + private awaitTaskRunResult(taskId: string, cancel?: Promise): Promise { return new Promise((resolve, reject) => { - const subscription = this.taskPollingLifecycle.events - // listen for all events related to the current task + // listen for all events related to the current task + const subscription = merge( + this.taskPollingLifecycle.events, + this.ephemeralTaskLifecycle.events + ) .pipe(filter(({ id }: TaskLifecycleEvent) => id === taskId)) .subscribe((taskEvent: TaskLifecycleEvent) => { if (isTaskClaimEvent(taskEvent)) { @@ -161,7 +233,7 @@ export class TaskScheduling { // resolve if the task has run sucessfully if (isTaskRunEvent(taskEvent)) { subscription.unsubscribe(); - resolve({ id: (taskInstance as RanTask).task.id }); + resolve(pick((taskInstance as RanTask).task, ['id', 'state'])); } }, async (errorResult: ErrResultOf) => { @@ -182,6 +254,12 @@ export class TaskScheduling { ); } }); + + if (cancel) { + cancel.then(() => { + subscription.unsubscribe(); + }); + } }); } @@ -216,3 +294,14 @@ export class TaskScheduling { ); } } + +const cancellablePromise = () => { + const boolStream = new Subject(); + return { + cancel: () => boolStream.next(true), + resolveOnCancel: boolStream + .pipe(take(1)) + .toPromise() + .then(() => {}), + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/source_status/types.ts b/x-pack/plugins/task_manager/server/usage/index.ts similarity index 74% rename from x-pack/plugins/security_solution/server/lib/source_status/types.ts rename to x-pack/plugins/task_manager/server/usage/index.ts index 02fa60310b70aa..2f52014fa40efd 100644 --- a/x-pack/plugins/security_solution/server/lib/source_status/types.ts +++ b/x-pack/plugins/task_manager/server/usage/index.ts @@ -5,8 +5,4 @@ * 2.0. */ -export interface ApmServiceNameAgg { - total_service_names: { - value: number; - }; -} +export { registerTaskManagerUsageCollector } from './task_manager_usage_collector'; diff --git a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts new file mode 100644 index 00000000000000..4b993a4e0629de --- /dev/null +++ b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Subject } from 'rxjs'; +import { merge } from 'lodash'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { + Collector, + createCollectorFetchContextWithKibanaMock, + createUsageCollectionSetupMock, +} from 'src/plugins/usage_collection/server/mocks'; +import { HealthStatus } from '../monitoring'; +import { MonitoredHealth } from '../routes/health'; +import { TaskPersistence } from '../task_events'; +import { registerTaskManagerUsageCollector } from './task_manager_usage_collector'; +import { sleep } from '../test_utils'; + +describe('registerTaskManagerUsageCollector', () => { + let collector: Collector; + const logger = loggingSystemMock.createLogger(); + + it('should report telemetry on the ephemeral queue', async () => { + const monitoringStats$ = new Subject(); + const usageCollectionMock = createUsageCollectionSetupMock(); + const fetchContext = createCollectorFetchContextWithKibanaMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = new Collector(logger, config); + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + registerTaskManagerUsageCollector(usageCollectionMock, monitoringStats$, true, 10); + + const mockHealth = getMockMonitoredHealth(); + monitoringStats$.next(mockHealth); + await sleep(1001); + + expect(usageCollectionMock.makeUsageCollector).toBeCalled(); + const telemetry = await collector.fetch(fetchContext); + expect(telemetry).toMatchObject({ + ephemeral_tasks_enabled: true, + ephemeral_request_capacity: 10, + ephemeral_stats: { + status: mockHealth.stats.ephemeral?.status, + load: mockHealth.stats.ephemeral?.value.load, + executions_per_cycle: mockHealth.stats.ephemeral?.value.executionsPerCycle, + queued_tasks: mockHealth.stats.ephemeral?.value.queuedTasks, + }, + }); + }); +}); + +function getMockMonitoredHealth(overrides = {}): MonitoredHealth { + const stub: MonitoredHealth = { + id: '1', + status: HealthStatus.OK, + timestamp: new Date().toISOString(), + last_update: new Date().toISOString(), + stats: { + configuration: { + timestamp: new Date().toISOString(), + status: HealthStatus.OK, + value: { + max_workers: 10, + poll_interval: 3000, + max_poll_inactivity_cycles: 10, + request_capacity: 1000, + monitored_aggregated_stats_refresh_rate: 5000, + monitored_stats_running_average_window: 50, + monitored_task_execution_thresholds: { + default: { + error_threshold: 90, + warn_threshold: 80, + }, + custom: {}, + }, + }, + }, + workload: { + timestamp: new Date().toISOString(), + status: HealthStatus.OK, + value: { + count: 4, + task_types: { + actions_telemetry: { count: 2, status: { idle: 2 } }, + alerting_telemetry: { count: 1, status: { idle: 1 } }, + session_cleanup: { count: 1, status: { idle: 1 } }, + }, + schedule: [], + overdue: 0, + overdue_non_recurring: 0, + estimatedScheduleDensity: [], + non_recurring: 20, + owner_ids: 2, + estimated_schedule_density: [], + capacity_requirements: { + per_minute: 150, + per_hour: 360, + per_day: 820, + }, + }, + }, + ephemeral: { + status: HealthStatus.OK, + timestamp: new Date().toISOString(), + value: { + load: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + executionsPerCycle: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + queuedTasks: { + p50: 4, + p90: 6, + p95: 6, + p99: 6, + }, + }, + }, + runtime: { + timestamp: new Date().toISOString(), + status: HealthStatus.OK, + value: { + drift: { + p50: 1000, + p90: 2000, + p95: 2500, + p99: 3000, + }, + drift_by_type: {}, + load: { + p50: 1000, + p90: 2000, + p95: 2500, + p99: 3000, + }, + execution: { + duration: {}, + duration_by_persistence: {}, + persistence: { + [TaskPersistence.Recurring]: 10, + [TaskPersistence.NonRecurring]: 10, + [TaskPersistence.Ephemeral]: 10, + }, + result_frequency_percent_as_number: {}, + }, + polling: { + last_successful_poll: new Date().toISOString(), + duration: [500, 400, 3000], + claim_conflicts: [0, 100, 75], + claim_mismatches: [0, 100, 75], + result_frequency_percent_as_number: [ + 'NoTasksClaimed', + 'NoTasksClaimed', + 'NoTasksClaimed', + ], + }, + }, + }, + }, + }; + return (merge(stub, overrides) as unknown) as MonitoredHealth; +} diff --git a/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts new file mode 100644 index 00000000000000..3eff2370ec0cb2 --- /dev/null +++ b/x-pack/plugins/task_manager/server/usage/task_manager_usage_collector.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Observable } from 'rxjs'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { MonitoredHealth } from '../routes/health'; +import { TaskManagerUsage } from './types'; + +export function createTaskManagerUsageCollector( + usageCollection: UsageCollectionSetup, + monitoringStats$: Observable, + ephemeralTasksEnabled: boolean, + ephemeralRequestCapacity: number +) { + let lastMonitoredHealth: MonitoredHealth | null = null; + monitoringStats$.subscribe((health) => { + lastMonitoredHealth = health; + }); + + return usageCollection.makeUsageCollector({ + type: 'task_manager', + isReady: async () => { + return Boolean(lastMonitoredHealth); + }, + fetch: async () => { + return { + ephemeral_tasks_enabled: ephemeralTasksEnabled, + ephemeral_request_capacity: ephemeralRequestCapacity, + ephemeral_stats: { + status: lastMonitoredHealth?.stats.ephemeral?.status ?? '', + queued_tasks: { + p50: lastMonitoredHealth?.stats.ephemeral?.value.queuedTasks.p50 ?? 0, + p90: lastMonitoredHealth?.stats.ephemeral?.value.queuedTasks.p90 ?? 0, + p95: lastMonitoredHealth?.stats.ephemeral?.value.queuedTasks.p95 ?? 0, + p99: lastMonitoredHealth?.stats.ephemeral?.value.queuedTasks.p99 ?? 0, + }, + load: { + p50: lastMonitoredHealth?.stats.ephemeral?.value.load.p50 ?? 0, + p90: lastMonitoredHealth?.stats.ephemeral?.value.load.p90 ?? 0, + p95: lastMonitoredHealth?.stats.ephemeral?.value.load.p95 ?? 0, + p99: lastMonitoredHealth?.stats.ephemeral?.value.load.p99 ?? 0, + }, + executions_per_cycle: { + p50: lastMonitoredHealth?.stats.ephemeral?.value.executionsPerCycle.p50 ?? 0, + p90: lastMonitoredHealth?.stats.ephemeral?.value.executionsPerCycle.p90 ?? 0, + p95: lastMonitoredHealth?.stats.ephemeral?.value.executionsPerCycle.p95 ?? 0, + p99: lastMonitoredHealth?.stats.ephemeral?.value.executionsPerCycle.p99 ?? 0, + }, + }, + }; + }, + schema: { + ephemeral_tasks_enabled: { type: 'boolean' }, + ephemeral_request_capacity: { type: 'short' }, + ephemeral_stats: { + status: { type: 'keyword' }, + queued_tasks: { + p50: { type: 'long' }, + p90: { type: 'long' }, + p95: { type: 'long' }, + p99: { type: 'long' }, + }, + load: { + p50: { type: 'long' }, + p90: { type: 'long' }, + p95: { type: 'long' }, + p99: { type: 'long' }, + }, + executions_per_cycle: { + p50: { type: 'long' }, + p90: { type: 'long' }, + p95: { type: 'long' }, + p99: { type: 'long' }, + }, + }, + }, + }); +} + +export function registerTaskManagerUsageCollector( + usageCollection: UsageCollectionSetup, + monitoringStats$: Observable, + ephemeralTasksEnabled: boolean, + ephemeralRequestCapacity: number +) { + const collector = createTaskManagerUsageCollector( + usageCollection, + monitoringStats$, + ephemeralTasksEnabled, + ephemeralRequestCapacity + ); + usageCollection.registerCollector(collector); +} diff --git a/x-pack/plugins/task_manager/server/usage/types.ts b/x-pack/plugins/task_manager/server/usage/types.ts new file mode 100644 index 00000000000000..78e948e21d0aa7 --- /dev/null +++ b/x-pack/plugins/task_manager/server/usage/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TaskManagerUsage { + ephemeral_tasks_enabled: boolean; + ephemeral_request_capacity: number; + ephemeral_stats: { + status: string; + queued_tasks: { + p50: number; + p90: number; + p95: number; + p99: number; + }; + load: { + p50: number; + p90: number; + p95: number; + p99: number; + }; + executions_per_cycle: { + p50: number; + p90: number; + p95: number; + p99: number; + }; + }; +} diff --git a/x-pack/plugins/task_manager/tsconfig.json b/x-pack/plugins/task_manager/tsconfig.json index a72b678da1f7c1..4b53dcac72c8ee 100644 --- a/x-pack/plugins/task_manager/tsconfig.json +++ b/x-pack/plugins/task_manager/tsconfig.json @@ -15,5 +15,6 @@ "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, ] } diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 02cff73bfc117d..270117eed84994 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5840,6 +5840,71 @@ } } }, + "task_manager": { + "properties": { + "ephemeral_tasks_enabled": { + "type": "boolean" + }, + "ephemeral_request_capacity": { + "type": "short" + }, + "ephemeral_stats": { + "properties": { + "status": { + "type": "keyword" + }, + "queued_tasks": { + "properties": { + "p50": { + "type": "long" + }, + "p90": { + "type": "long" + }, + "p95": { + "type": "long" + }, + "p99": { + "type": "long" + } + } + }, + "load": { + "properties": { + "p50": { + "type": "long" + }, + "p90": { + "type": "long" + }, + "p95": { + "type": "long" + }, + "p99": { + "type": "long" + } + } + }, + "executions_per_cycle": { + "properties": { + "p50": { + "type": "long" + }, + "p90": { + "type": "long" + }, + "p95": { + "type": "long" + }, + "p99": { + "type": "long" + } + } + } + } + } + } + }, "upgrade-assistant-telemetry": { "properties": { "features": { diff --git a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts index ad70d8bba82fd3..2a6e1b3e12bcf7 100644 --- a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts @@ -14,6 +14,7 @@ export type CellValueElementProps = EuiDataGridCellValueElementProps & { data: TimelineNonEcsData[]; eventId: string; // _id header: ColumnHeaderOptions; + isDraggable: boolean; linkValues: string[] | undefined; timelineId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/timelines/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap b/x-pack/plugins/timelines/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap new file mode 100644 index 00000000000000..142ed7a0d7175a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/empty_value/__snapshots__/empty_value.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmptyValue it renders against snapshot 1`] = ` +

+ (Empty String) +

+`; diff --git a/x-pack/plugins/timelines/public/components/empty_value/empty_value.test.tsx b/x-pack/plugins/timelines/public/components/empty_value/empty_value.test.tsx new file mode 100644 index 00000000000000..be9a086d8dc5b5 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/empty_value/empty_value.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mountWithIntl } from '@kbn/test/jest'; + +import { + defaultToEmptyTag, + getEmptyString, + getEmptyStringTag, + getEmptyTagValue, + getEmptyValue, + getOrEmptyTag, +} from '.'; +import { getMockTheme } from '../../mock/kibana_react.mock'; + +describe('EmptyValue', () => { + const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); + + test('it renders against snapshot', () => { + const wrapper = shallow(

{getEmptyString()}

); + expect(wrapper).toMatchSnapshot(); + }); + + describe('#getEmptyValue', () => { + test('should return an empty value', () => expect(getEmptyValue()).toBe('—')); + }); + + describe('#getEmptyString', () => { + test('should turn into an empty string place holder', () => { + const wrapper = mountWithIntl( + +

{getEmptyString()}

+
+ ); + expect(wrapper.text()).toBe('(Empty String)'); + }); + }); + + describe('#getEmptyTagValue', () => { + const wrapper = mount( + +

{getEmptyTagValue()}

+
+ ); + test('should return an empty tag value', () => expect(wrapper.text()).toBe('—')); + }); + + describe('#getEmptyStringTag', () => { + test('should turn into an span that has length of 1', () => { + const wrapper = mountWithIntl( + +

{getEmptyStringTag()}

+
+ ); + expect(wrapper.find('span')).toHaveLength(1); + }); + + test('should turn into an empty string tag place holder', () => { + const wrapper = mountWithIntl( + +

{getEmptyStringTag()}

+
+ ); + expect(wrapper.text()).toBe(getEmptyString()); + }); + }); + + describe('#defaultToEmptyTag', () => { + test('should default to an empty value when a value is null', () => { + const wrapper = mount( + +

{defaultToEmptyTag(null)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default to an empty value when a value is undefined', () => { + const wrapper = mount( + +

{defaultToEmptyTag(undefined)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should return a deep path value', () => { + const test = { + a: { + b: { + c: 1, + }, + }, + }; + const wrapper = mount(

{defaultToEmptyTag(test.a.b.c)}

); + expect(wrapper.text()).toBe('1'); + }); + }); + + describe('#getOrEmptyTag', () => { + test('should default empty value when a deep rooted value is null', () => { + const test = { + a: { + b: { + c: null, + }, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default empty value when a deep rooted value is undefined', () => { + const test = { + a: { + b: { + c: undefined, + }, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should default empty value when a deep rooted value is missing', () => { + const test = { + a: { + b: {}, + }, + }; + const wrapper = mount( + +

{getOrEmptyTag('a.b.c', test)}

+
+ ); + expect(wrapper.text()).toBe(getEmptyValue()); + }); + + test('should return a deep path value', () => { + const test = { + a: { + b: { + c: 1, + }, + }, + }; + const wrapper = mount(

{getOrEmptyTag('a.b.c', test)}

); + expect(wrapper.text()).toBe('1'); + }); + }); +}); diff --git a/x-pack/plugins/timelines/public/components/empty_value/index.tsx b/x-pack/plugins/timelines/public/components/empty_value/index.tsx new file mode 100644 index 00000000000000..86efb4a78277ac --- /dev/null +++ b/x-pack/plugins/timelines/public/components/empty_value/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, isString } from 'lodash/fp'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from './translations'; + +const EmptyWrapper = styled.span` + color: ${(props) => props.theme.eui.euiColorMediumShade}; +`; + +EmptyWrapper.displayName = 'EmptyWrapper'; + +export const getEmptyValue = () => '—'; +export const getEmptyString = () => `(${i18n.EMPTY_STRING})`; + +export const getEmptyTagValue = () => {getEmptyValue()}; +export const getEmptyStringTag = () => {getEmptyString()}; + +export const defaultToEmptyTag = (item: T): JSX.Element => { + if (item == null) { + return getEmptyTagValue(); + } else if (isString(item) && item === '') { + return getEmptyStringTag(); + } else { + return <>{item}; + } +}; + +export const getOrEmptyTag = (path: string, item: unknown): JSX.Element => { + const text = get(path, item); + return getOrEmptyTagFromValue(text); +}; + +export const getOrEmptyTagFromValue = (value: string | number | null | undefined): JSX.Element => { + if (value == null) { + return getEmptyTagValue(); + } else if (value === '') { + return getEmptyStringTag(); + } else { + return <>{value}; + } +}; diff --git a/x-pack/plugins/timelines/public/components/empty_value/translations.ts b/x-pack/plugins/timelines/public/components/empty_value/translations.ts new file mode 100644 index 00000000000000..20c822c67dfb3a --- /dev/null +++ b/x-pack/plugins/timelines/public/components/empty_value/translations.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const EMPTY_STRING = i18n.translate('xpack.timelines.emptyString.emptyStringDescription', { + defaultMessage: 'Empty String', +}); diff --git a/x-pack/plugins/timelines/public/components/fields_browser/index.tsx b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx new file mode 100644 index 00000000000000..ac121f9afdd580 --- /dev/null +++ b/x-pack/plugins/timelines/public/components/fields_browser/index.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { Store } from 'redux'; +import { Provider } from 'react-redux'; +import { I18nProvider } from '@kbn/i18n/react'; +import type { FieldBrowserProps } from '../t_grid/toolbar/fields_browser/types'; +import { StatefulFieldsBrowser } from '../t_grid/toolbar/fields_browser'; +import { + FIELD_BROWSER_WIDTH, + FIELD_BROWSER_HEIGHT, +} from '../t_grid/toolbar/fields_browser/helpers'; + +const EMPTY_BROWSER_FIELDS = {}; +export type FieldBrowserWrappedProps = Omit & { + width?: FieldBrowserProps['width']; + height?: FieldBrowserProps['height']; +}; +export type FieldBrowserWrappedComponentProps = FieldBrowserWrappedProps & { + store: Store; +}; + +export const FieldBrowserWrappedComponent = (props: FieldBrowserWrappedComponentProps) => { + const { store, ...restProps } = props; + const fieldsBrowseProps = { + width: FIELD_BROWSER_WIDTH, + height: FIELD_BROWSER_HEIGHT, + ...restProps, + browserFields: restProps.browserFields ?? EMPTY_BROWSER_FIELDS, + }; + return ( + + + + + + ); +}; + +FieldBrowserWrappedComponent.displayName = 'FieldBrowserWrappedComponent'; + +// eslint-disable-next-line import/no-default-export +export { FieldBrowserWrappedComponent as default }; diff --git a/x-pack/plugins/timelines/public/components/index.tsx b/x-pack/plugins/timelines/public/components/index.tsx index 8bb4e6cb45853f..99595744648369 100644 --- a/x-pack/plugins/timelines/public/components/index.tsx +++ b/x-pack/plugins/timelines/public/components/index.tsx @@ -8,17 +8,17 @@ import React from 'react'; import { Provider } from 'react-redux'; import { I18nProvider } from '@kbn/i18n/react'; -import { Store } from 'redux'; +import type { Store } from 'redux'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; -import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import type { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { createStore } from '../store/t_grid'; import { TGrid as TGridComponent } from './tgrid'; -import { TGridProps } from '../types'; +import type { TGridProps } from '../types'; import { DragDropContextWrapper } from './drag_and_drop'; import { initialTGridState } from '../store/t_grid/reducer'; -import { TGridIntegratedProps } from './t_grid/integrated'; +import type { TGridIntegratedProps } from './t_grid/integrated'; const EMPTY_BROWSER_FIELDS = {}; @@ -58,3 +58,4 @@ export * from './drag_and_drop'; export * from './draggables'; export * from './last_updated'; export * from './loading'; +export * from './fields_browser'; diff --git a/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx b/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx index f7d81db6709832..4c179efd8b7d3f 100644 --- a/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/last_updated/index.test.tsx @@ -32,17 +32,6 @@ describe('LastUpdatedAt', () => { expect(wrapper.text()).toEqual(' Updated 2 minutes ago'); }); - test('it only renders icon if "compact" is true', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.text()).toEqual(''); - expect(wrapper.find('[data-test-subj="last-updated-at-clock-icon"]').exists()).toBeTruthy(); - }); - test('it renders updating text if "showUpdating" is true', () => { const wrapper = mount( diff --git a/x-pack/plugins/timelines/public/components/last_updated/index.tsx b/x-pack/plugins/timelines/public/components/last_updated/index.tsx index 344cb36791dd55..f60b0e147b6895 100644 --- a/x-pack/plugins/timelines/public/components/last_updated/index.tsx +++ b/x-pack/plugins/timelines/public/components/last_updated/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedRelative } from '@kbn/i18n/react'; import React, { useEffect, useMemo, useState } from 'react'; @@ -66,14 +66,9 @@ export const LastUpdatedAt = React.memo( return ( - - - } + content={} > - - + {updateText} diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx index 82d872d30c273d..9ee64e0e45be3a 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/data_driven_columns/stateful_cell.tsx @@ -48,6 +48,7 @@ const StatefulCellComponent = ({ eventId, data, header, + isDraggable: true, isExpandable: true, isExpanded: false, isDetails: false, diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx index b8533f33a82e9b..a3de8654ec1c5c 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.test.tsx @@ -80,6 +80,15 @@ describe('Body', () => { }; describe('rendering', () => { + test('it renders the body data grid', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="body-data-grid"]').first().exists()).toEqual(true); + }); + test('it renders the column headers', () => { const wrapper = mount( @@ -87,7 +96,7 @@ describe('Body', () => { ); - expect(wrapper.find('[data-test-subj="column-headers"]').first().exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="dataGridHeader"]').first().exists()).toEqual(true); }); test('it renders the scroll container', () => { @@ -97,7 +106,7 @@ describe('Body', () => { ); - expect(wrapper.find('[data-test-subj="timeline-body"]').first().exists()).toEqual(true); + expect(wrapper.find('div.euiDataGrid__overflow').first().exists()).toEqual(true); }); test('it renders events', () => { @@ -107,10 +116,10 @@ describe('Body', () => { ); - expect(wrapper.find('[data-test-subj="events"]').first().exists()).toEqual(true); + expect(wrapper.find('div.euiDataGridRowCell').first().exists()).toEqual(true); }); - test('it renders a tooltip for timestamp', () => { + test.skip('it renders a tooltip for timestamp', () => { const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); const testProps = { ...props, columnHeaders: headersJustTimestamp }; const wrapper = mount( diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index 51227c0e811f26..3b81bdc2774f0d 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { noop } from 'lodash/fp'; +import { + EuiDataGrid, + EuiDataGridCellValueElementProps, + EuiDataGridControlColumn, + EuiDataGridStyle, + EuiDataGridToolBarVisibilityOptions, +} from '@elastic/eui'; +import { getOr } from 'lodash/fp'; import memoizeOne from 'memoize-one'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import deepEqual from 'fast-deep-equal'; -import { - ARIA_COLINDEX_ATTRIBUTE, - ARIA_ROWINDEX_ATTRIBUTE, - FIRST_ARIA_INDEX, - onKeyDownFocusHandler, -} from '../../../../common'; -import { DEFAULT_COLUMN_MIN_WIDTH } from './constants'; -import { RowRendererId, TimelineId, TimelineTabs } from '../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../common/types/timeline'; // eslint-disable-next-line no-duplicate-imports import type { CellValueElementProps, @@ -26,35 +25,35 @@ import type { ControlColumnProps, RowRenderer, } from '../../../../common/types/timeline'; -import type { TimelineItem } from '../../../../common/search_strategy/timeline'; +import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline'; import { getActionsColumnWidth, getColumnHeaders } from './column_headers/helpers'; import { getEventIdToDataMapping } from './helpers'; import { Sort } from './sort'; -import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; -import { ColumnHeaders } from './column_headers'; -import { Events } from './events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { OnRowSelected, OnSelectAll } from '../types'; -import { tGridActions } from '../../../'; +import { StatefulFieldsBrowser, tGridActions } from '../../../'; import { TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; -import { plainRowRenderer } from './renderers/plain_row_renderer'; +import { RowAction } from './row_action'; +import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from '../toolbar/fields_browser/helpers'; +import * as i18n from './translations'; interface OwnProps { activePage: number; + additionalControls?: React.ReactNode; browserFields: BrowserFields; data: TimelineItem[]; id: string; isEventViewer?: boolean; - sort: Sort[]; + leadingControlColumns: ControlColumnProps[]; renderCellValue: (props: CellValueElementProps) => React.ReactNode; rowRenderers: RowRenderer[]; - leadingControlColumns: ControlColumnProps[]; - trailingControlColumns: ControlColumnProps[]; + sort: Sort[]; tabType: TimelineTabs; + trailingControlColumns: ControlColumnProps[]; totalPages: number; onRuleChange?: () => void; } @@ -68,15 +67,90 @@ export const hasAdditionalActions = (id: TimelineId): boolean => const EXTRA_WIDTH = 4; // px +const MIN_ACTION_COLUMN_WIDTH = 96; // px + +const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = []; + +const EmptyHeaderCellRender: ComponentType = () => null; + +const gridStyle: EuiDataGridStyle = { border: 'none', fontSize: 's', header: 'underline' }; + +const transformControlColumns = ({ + actionColumnsWidth, + columnHeaders, + controlColumns, + data, + isEventViewer = false, + loadingEventIds, + onRowSelected, + onRuleChange, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, +}: { + actionColumnsWidth: number; + columnHeaders: ColumnHeaderOptions[]; + controlColumns: ControlColumnProps[]; + data: TimelineItem[]; + isEventViewer?: boolean; + loadingEventIds: string[]; + onRowSelected: OnRowSelected; + onRuleChange?: () => void; + selectedEventIds: Record; + showCheckboxes: boolean; + tabType: TimelineTabs; + timelineId: string; +}): EuiDataGridControlColumn[] => + controlColumns.map( + ({ id: columnId, headerCellRender = EmptyHeaderCellRender, rowCellRender, width }, i) => ({ + id: `${columnId}`, + headerCellRender: headerCellRender as ComponentType, + // eslint-disable-next-line react/display-name + rowCellRender: ({ + isDetails, + isExpandable, + isExpanded, + rowIndex, + setCellProps, + }: EuiDataGridCellValueElementProps) => ( + + ), + width: actionColumnsWidth, + }) + ); + export type StatefulBodyProps = OwnProps & PropsFromRedux; /** * The Body component is used everywhere timeline is used within the security application. It is the highest level component * that is shared across all implementations of the timeline. */ + export const BodyComponent = React.memo( ({ activePage, + additionalControls, browserFields, columnHeaders, data, @@ -95,10 +169,9 @@ export const BodyComponent = React.memo( sort, tabType, totalPages, - leadingControlColumns = [], - trailingControlColumns = [], + leadingControlColumns = EMPTY_CONTROL_COLUMNS, + trailingControlColumns = EMPTY_CONTROL_COLUMNS, }) => { - const containerRef = useRef(null); const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); const { queryFields, selectAll } = useDeepEqualSelector((state) => getManageTimeline(state, id) @@ -141,152 +214,132 @@ export const BodyComponent = React.memo( } }, [isSelectAllChecked, onSelectAll, selectAll]); - const enabledRowRenderers = useMemo(() => { - if ( - excludedRowRendererIds && - excludedRowRendererIds.length === Object.keys(RowRendererId).length - ) - return [plainRowRenderer]; - - if (!excludedRowRendererIds) return rowRenderers; + const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo( + () => ({ + additionalControls: ( + <> + {additionalControls ?? null} + { + + } + + ), + showColumnSelector: { allowHide: false, allowReorder: true }, + showStyleSelector: false, + }), + [additionalControls, browserFields, columnHeaders, id] + ); - return rowRenderers.filter((rowRenderer) => !excludedRowRendererIds.includes(rowRenderer.id)); - }, [excludedRowRendererIds, rowRenderers]); + const [sortingColumns, setSortingColumns] = useState([]); - const actionsColumnWidth = useMemo( - () => - getActionsColumnWidth( - isEventViewer, - showCheckboxes, - hasAdditionalActions(id as TimelineId) - ? DEFAULT_ICON_BUTTON_WIDTH * NUM_OF_ICON_IN_TIMELINE_ROW + EXTRA_WIDTH - : 0 - ), - [isEventViewer, showCheckboxes, id] + const onSort = useCallback( + (columns) => { + setSortingColumns(columns); + }, + [setSortingColumns] ); - const columnWidths = useMemo( + const [visibleColumns, setVisibleColumns] = useState(() => + columnHeaders.map(({ id: cid }) => cid) + ); // initializes to the full set of columns + + useEffect(() => { + setVisibleColumns(columnHeaders.map(({ id: cid }) => cid)); + }, [columnHeaders]); + + const [leadingTGridControlColumns, trailingTGridControlColumns] = useMemo( () => - columnHeaders.reduce( - (totalWidth, header) => totalWidth + (header.initialWidth ?? DEFAULT_COLUMN_MIN_WIDTH), - 0 + [leadingControlColumns, trailingControlColumns].map((controlColumns) => + transformControlColumns({ + columnHeaders, + controlColumns, + data, + isEventViewer, + actionColumnsWidth: hasAdditionalActions(id as TimelineId) + ? getActionsColumnWidth( + isEventViewer, + showCheckboxes, + DEFAULT_ICON_BUTTON_WIDTH * NUM_OF_ICON_IN_TIMELINE_ROW + EXTRA_WIDTH + ) + : controlColumns.reduce((acc, c) => acc + (c.width ?? MIN_ACTION_COLUMN_WIDTH), 0), + loadingEventIds, + onRowSelected, + onRuleChange, + selectedEventIds, + showCheckboxes, + tabType, + timelineId: id, + }) ), - [columnHeaders] + [ + columnHeaders, + data, + id, + isEventViewer, + leadingControlColumns, + loadingEventIds, + onRowSelected, + onRuleChange, + selectedEventIds, + showCheckboxes, + tabType, + trailingControlColumns, + ] ); - const leadingActionColumnsWidth = useMemo(() => { - return leadingControlColumns - ? leadingControlColumns.reduce( - (totalWidth, header) => - header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, - 0 - ) - : 0; - }, [actionsColumnWidth, leadingControlColumns]); - - const trailingActionColumnsWidth = useMemo(() => { - return trailingControlColumns - ? trailingControlColumns.reduce( - (totalWidth, header) => - header.width ? totalWidth + header.width : totalWidth + actionsColumnWidth, - 0 - ) - : 0; - }, [actionsColumnWidth, trailingControlColumns]); - - const totalWidth = useMemo(() => { - return columnWidths + leadingActionColumnsWidth + trailingActionColumnsWidth; - }, [columnWidths, leadingActionColumnsWidth, trailingActionColumnsWidth]); - - const [lastFocusedAriaColindex] = useState(FIRST_ARIA_INDEX); - - const columnCount = useMemo(() => { - return columnHeaders.length + trailingControlColumns.length + leadingControlColumns.length; - }, [columnHeaders, trailingControlColumns, leadingControlColumns]); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - onKeyDownFocusHandler({ - colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, - containerElement: containerRef.current, - event: e, - maxAriaColindex: columnHeaders.length + 1, - maxAriaRowindex: data.length + 1, - onColumnFocused: noop, - rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, - }); - }, - [columnHeaders.length, containerRef, data.length] - ); + const renderTGridCellValue: (x: EuiDataGridCellValueElementProps) => React.ReactNode = ({ + columnId, + rowIndex, + setCellProps, + }) => { + const rowData = rowIndex < data.length ? data[rowIndex].data : null; + const header = columnHeaders.find((h) => h.id === columnId); + const eventId = rowIndex < data.length ? data[rowIndex]._id : null; + + if (rowData == null || header == null || eventId == null) { + return null; + } + + return renderCellValue({ + columnId: header.id, + eventId, + data: rowData, + header, + isDraggable: false, + isExpandable: true, + isExpanded: false, + isDetails: false, + linkValues: getOr([], header.linkField ?? '', data[rowIndex].ecs), + rowIndex, + setCellProps, + timelineId: tabType != null ? `${id}-${tabType}` : id, + }); + }; + return ( - <> - - - - - - - - - + ); - }, - (prevProps, nextProps) => - deepEqual(prevProps.browserFields, nextProps.browserFields) && - deepEqual(prevProps.columnHeaders, nextProps.columnHeaders) && - deepEqual(prevProps.data, nextProps.data) && - deepEqual(prevProps.excludedRowRendererIds, nextProps.excludedRowRendererIds) && - deepEqual(prevProps.sort, nextProps.sort) && - deepEqual(prevProps.selectedEventIds, nextProps.selectedEventIds) && - deepEqual(prevProps.loadingEventIds, nextProps.loadingEventIds) && - prevProps.id === nextProps.id && - prevProps.isEventViewer === nextProps.isEventViewer && - prevProps.isSelectAllChecked === nextProps.isSelectAllChecked && - prevProps.renderCellValue === nextProps.renderCellValue && - prevProps.rowRenderers === nextProps.rowRenderers && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.tabType === nextProps.tabType + } ); BodyComponent.displayName = 'BodyComponent'; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx new file mode 100644 index 00000000000000..001578b01f09fe --- /dev/null +++ b/x-pack/plugins/timelines/public/components/t_grid/body/row_action/index.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy'; +import { + ColumnHeaderOptions, + ControlColumnProps, + OnRowSelected, + TimelineExpandedDetailType, + TimelineTabs, +} from '../../../../../common/types/timeline'; +import { getMappedNonEcsValue } from '../data_driven_columns'; +import { tGridActions } from '../../../../store/t_grid'; + +type Props = EuiDataGridCellValueElementProps & { + columnHeaders: ColumnHeaderOptions[]; + controlColumn: ControlColumnProps; + data: TimelineItem[]; + index: number; + isEventViewer: boolean; + loadingEventIds: Readonly; + onRowSelected: OnRowSelected; + onRuleChange?: () => void; + selectedEventIds: Readonly>; + showCheckboxes: boolean; + tabType?: TimelineTabs; + timelineId: string; + width: number; +}; + +const RowActionComponent = ({ + columnHeaders, + controlColumn, + data, + index, + isEventViewer, + loadingEventIds, + onRowSelected, + onRuleChange, + rowIndex, + selectedEventIds, + showCheckboxes, + tabType, + timelineId, + width, +}: Props) => { + const { data: timelineNonEcsData, ecs: ecsData, _id: eventId, _index: indexName } = useMemo( + () => data[rowIndex], + [data, rowIndex] + ); + + const dispatch = useDispatch(); + + const columnValues = useMemo( + () => + columnHeaders + .map( + (header) => + getMappedNonEcsValue({ + data: timelineNonEcsData, + fieldName: header.id, + }) ?? [] + ) + .join(' '), + [columnHeaders, timelineNonEcsData] + ); + + const handleOnEventDetailPanelOpened = useCallback(() => { + const updatedExpandedDetail: TimelineExpandedDetailType = { + panelView: 'eventDetail', + params: { + eventId, + indexName: indexName ?? '', + }, + }; + + dispatch( + tGridActions.toggleDetailPanel({ + ...updatedExpandedDetail, + tabType, + timelineId, + }) + ); + }, [dispatch, eventId, indexName, tabType, timelineId]); + + const Action = controlColumn.rowCellRender; + + if (data.length === 0 || rowIndex >= data.length) { + return ; + } + + return ( + <> + {Action && ( + + )} + + ); +}; + +export const RowAction = React.memo(RowActionComponent); diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts index 1a00a4eaf6bc6f..c45a00a0516f47 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/body/translations.ts @@ -14,6 +14,10 @@ export const NOTES_TOOLTIP = i18n.translate( } ); +export const TGRID_BODY_ARIA_LABEL = i18n.translate('xpack.timelines.tgrid.body.ariaLabel', { + defaultMessage: 'Alerts', +}); + export const NOTES_DISABLE_TOOLTIP = i18n.translate( 'xpack.timelines.timeline.body.notes.disableEventTooltip', { diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx b/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx index fe57ab8d2d0f3d..c5f8cb4afd3187 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/index.test.tsx @@ -14,7 +14,6 @@ import { FooterComponent, PagingControlComponent } from './index'; describe('Footer Timeline Component', () => { const loadMore = jest.fn(); - const updatedAt = 1546878704036; const serverSideEventCount = 15546; const itemsCount = 2; @@ -24,7 +23,6 @@ describe('Footer Timeline Component', () => { { { { { { { { width < 600; @@ -100,6 +97,7 @@ export const EventsCountComponent = ({ isOpen, items, itemsCount, + itemsPerPage, onClick, serverSideEventCount, }: { @@ -108,51 +106,40 @@ export const EventsCountComponent = ({ isOpen: boolean; items: React.ReactElement[]; itemsCount: number; + itemsPerPage: number; onClick: () => void; serverSideEventCount: number; footerText: string | React.ReactNode; }) => { - const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ - serverSideEventCount, - ]); - return ( -
- - - {itemsCount} - - - {` ${i18n.OF} `} - - } - isOpen={isOpen} - closePopover={closePopover} - panelPaddingSize="none" + const button = useMemo( + () => ( + - - - - - - {totalCount} - {' '} - {documentType} - - -
+ {i18n.ROWS_PER_PAGE(itemsPerPage)} + + ), + [itemsPerPage, onClick] + ); + + return ( + + + ); }; @@ -211,7 +198,6 @@ export const PagingControl = React.memo(PagingControlComponent); PagingControl.displayName = 'PagingControl'; interface FooterProps { - updatedAt: number; activePage: number; height: number; id: string; @@ -227,7 +213,6 @@ interface FooterProps { /** Renders a loading indicator and paging controls */ export const FooterComponent = ({ activePage, - updatedAt, height, id, isLive, @@ -341,6 +326,7 @@ export const FooterComponent = ({ isOpen={isPopoverOpen} items={rowItems} itemsCount={itemsCount} + itemsPerPage={itemsPerPage} onClick={onButtonClick} serverSideEventCount={totalCount} /> @@ -378,10 +364,6 @@ export const FooterComponent = ({ /> )} - - - - ); diff --git a/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts b/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts index e237ca39e10abc..c2417f34530652 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts +++ b/x-pack/plugins/timelines/public/components/t_grid/footer/translations.ts @@ -27,6 +27,12 @@ export const LOADING = i18n.translate('xpack.timelines.footer.loadingLabel', { defaultMessage: 'Loading', }); +export const ROWS_PER_PAGE = (rowsPerPage: number) => + i18n.translate('xpack.timelines.footer.rowsPerPageLabel', { + values: { rowsPerPage }, + defaultMessage: `Rows per page: {rowsPerPage}`, + }); + export const TOTAL_COUNT_OF_EVENTS = i18n.translate('xpack.timelines.footer.totalCountOfEvents', { defaultMessage: 'events', }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index d52174b02f88eb..a6ded88fae96bf 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -41,7 +41,8 @@ import { useTimelineEvents } from '../../../container'; import { HeaderSection } from '../header_section'; import { StatefulBody } from '../body'; import { Footer, footerHeight } from '../footer'; -import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../styles'; +import { LastUpdatedAt } from '../..'; +import { AlertCount, SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexItem } from '../styles'; import * as i18n from './translations'; import { ExitFullScreen } from '../../exit_full_screen'; import { Sort } from '../body/sort'; @@ -49,7 +50,7 @@ import { InspectButtonContainer } from '../../inspect'; export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px const UTILITY_BAR_HEIGHT = 19; // px -const COMPACT_HEADER_HEIGHT = EVENTS_VIEWER_HEADER_HEIGHT - UTILITY_BAR_HEIGHT; // px +const COMPACT_HEADER_HEIGHT = 36; // px const UtilityBar = styled.div` height: ${UTILITY_BAR_HEIGHT}px; @@ -176,7 +177,7 @@ const TGridIntegratedComponent: React.FC = ({ const [isQueryLoading, setIsQueryLoading] = useState(false); const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []); - const unit = useMemo(() => (n: number) => i18n.UNIT(n), []); + const unit = useMemo(() => (n: number) => i18n.ALERTS_UNIT(n), []); const { queryFields, title } = useDeepEqualSelector((state) => getManageTimeline(state, id ?? '') ); @@ -257,13 +258,12 @@ const TGridIntegratedComponent: React.FC = ({ ); const subtitle = useMemo( - () => - `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${ - unit && unit(totalCountMinusDeleted) - }`, + () => `${totalCountMinusDeleted.toLocaleString()} ${unit && unit(totalCountMinusDeleted)}`, [totalCountMinusDeleted, unit] ); + const additionalControls = useMemo(() => {subtitle}, [subtitle]); + const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ deletedEventIds, events, @@ -295,8 +295,10 @@ const TGridIntegratedComponent: React.FC = ({ id={!resolverIsShowing(graphEventId) ? id : undefined} inspect={inspect} loading={loading} - height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} - subtitle={utilityBar ? undefined : subtitle} + height={ + headerFilterGroup == null ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT + } + subtitle={utilityBar} title={globalFullScreen ? titleWithExitFullScreen : justTitle} > {HeaderSectionContent} @@ -308,10 +310,17 @@ const TGridIntegratedComponent: React.FC = ({ data-timeline-id={id} data-test-subj={`events-container-loading-${loading}`} > + + + + + + = ({