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/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/spaces/images/edit-space-feature-visibility.png b/docs/spaces/images/edit-space-feature-visibility.png index 0132337f4a7c66..f1852d3cc03b50 100644 Binary files a/docs/spaces/images/edit-space-feature-visibility.png and b/docs/spaces/images/edit-space-feature-visibility.png differ diff --git a/docs/spaces/images/edit-space.png b/docs/spaces/images/edit-space.png index c78e0d14b0d4c8..9785dd9e77aba2 100644 Binary files a/docs/spaces/images/edit-space.png and b/docs/spaces/images/edit-space.png differ 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..49d4bdbc2de64b 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -107,7 +107,7 @@ pageLoadAssetSize: dataVisualizer: 27530 banners: 17946 mapsEms: 26072 - timelines: 251886 + timelines: 330000 screenshotMode: 17856 visTypePie: 35583 expressionRevealImage: 25675 diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts deleted file mode 100644 index b03ee16d2f7463..00000000000000 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/packages/kbn-ui-shared-deps/src/public_path_module_creator.ts +++ /dev/null @@ -1,9 +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. - */ - -// stub 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/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/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/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/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/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index e8f6afc365f5d1..00133d720d8842 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -132,6 +132,11 @@ export class TimePickerPageObject extends FtrService { await this.testSubjects.click('superDatePickerAbsoluteTab'); await this.testSubjects.click('superDatePickerAbsoluteDateInput'); await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); + await this.browser.pressKeys(this.browser.keys.ESCAPE); + + await this.retry.waitFor('Timepicker popover to close', async () => { + return !(await this.testSubjects.exists('superDatePickerAbsoluteDateInput')); + }); const superDatePickerApplyButtonExists = await this.testSubjects.exists( 'superDatePickerApplyTimeButton' 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/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/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/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space.test.tsx.snap new file mode 100644 index 00000000000000..51943c7581273b --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + +

+ +

+ + } + > + + + + + + + } + labelType="label" + > + + + + } + isInvalid={true} + label={ + + } + labelType="label" + > + + +
+ +

+ Choose how your space avatar appears across Kibana. +

+ } + > + + + + } + fullWidth={true} + title={ + +

+ +

+
+ } + > + +
+
+`; diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap index 058b9ecdd0f8fb..3d709a5dca45a1 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/customize_space_avatar.test.tsx.snap @@ -7,58 +7,67 @@ exports[`renders without crashing 1`] = ` - - - - - diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/space_identifier.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/space_identifier.test.tsx.snap deleted file mode 100644 index f1b4b956eca88f..00000000000000 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/__snapshots__/space_identifier.test.tsx.snap +++ /dev/null @@ -1,63 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders without crashing 1`] = ` - - - - /s/ - - , - } - } - /> -

- } - isInvalid={false} - label={ -

- - - - -

- } - labelType="label" - > - -
-
-`; diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.test.tsx new file mode 100644 index 00000000000000..42195317e6731c --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { mountWithIntl, shallowWithIntl } from '@kbn/test/jest'; + +import { SpaceValidator } from '../../lib'; +import { CustomizeSpace } from './customize_space'; + +const validator = new SpaceValidator({ shouldValidate: true }); + +test('renders correctly', () => { + const wrapper = shallowWithIntl( + + ); + expect(wrapper).toMatchSnapshot(); +}); + +test('updates identifier, initials and color when name is changed', () => { + const space = { + id: 'space-1', + name: 'Space 1', + initials: 'S1', + color: '#ABCDEF', + }; + const changeHandler = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="name"]').simulate('change', { target: { value: 'Space 2' } }); + + expect(changeHandler).toHaveBeenCalledWith({ + ...space, + id: 'space-2', + name: 'Space 2', + initials: 'S2', + color: '#9170B8', + }); +}); + +test('does not update custom identifier, initials or color name is changed', () => { + const space = { + id: 'space-1', + name: 'Space 1', + initials: 'S1', + color: '#ABCDEF', + customAvatarInitials: true, + customAvatarColor: true, + }; + const changeHandler = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="name"]').simulate('change', { target: { value: 'Space 2' } }); + + expect(changeHandler).toHaveBeenCalledWith({ + ...space, + name: 'Space 2', + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.tsx index 4bbad58b5d139c..27de0e012faf94 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space.tsx @@ -5,31 +5,27 @@ * 2.0. */ -import type { EuiPopoverProps } from '@elastic/eui'; import { EuiDescribedFormGroup, EuiFieldText, EuiFormRow, EuiLoadingSpinner, - EuiPopover, - EuiSpacer, + EuiText, EuiTextArea, EuiTitle, } from '@elastic/eui'; import type { ChangeEvent } from 'react'; -import React, { Component, Fragment, lazy, Suspense } from 'react'; +import React, { Component, lazy, Suspense } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import type { Space } from 'src/plugins/spaces_oss/common'; -import { isReservedSpace } from '../../../../common'; -import { getSpaceAvatarComponent } from '../../../space_avatar'; +import { getSpaceAvatarComponent, getSpaceColor, getSpaceInitials } from '../../../space_avatar'; import type { SpaceValidator } from '../../lib'; import { toSpaceIdentifier } from '../../lib'; +import type { FormValues } from '../manage_space_page'; import { SectionPanel } from '../section_panel'; import { CustomizeSpaceAvatar } from './customize_space_avatar'; -import { SpaceIdentifier } from './space_identifier'; // No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. const LazySpaceAvatar = lazy(() => @@ -38,9 +34,9 @@ const LazySpaceAvatar = lazy(() => interface Props { validator: SpaceValidator; - space: Partial; + space: FormValues; editingExistingSpace: boolean; - onChange: (space: Partial) => void; + onChange: (space: FormValues) => void; } interface State { @@ -55,33 +51,31 @@ export class CustomizeSpace extends Component { }; public render() { - const { validator, editingExistingSpace } = this.props; - const { name = '', description = '' } = this.props.space; - const panelTitle = i18n.translate( - 'xpack.spaces.management.manageSpacePage.customizeSpaceTitle', - { - defaultMessage: 'Customize your space', - } - ); - - const extraPopoverProps: Partial = { - initialFocus: 'input[name="spaceInitials"]', - }; + const { validator, editingExistingSpace, space } = this.props; + const { name = '', description = '' } = space; + const panelTitle = i18n.translate('xpack.spaces.management.manageSpacePage.generalTitle', { + defaultMessage: 'General', + }); return ( - +

} - description={this.getPanelDescription()} + description={i18n.translate( + 'xpack.spaces.management.manageSpacePage.describeSpaceDescription', + { + defaultMessage: "Give your space a name that's memorable.", + } + )} fullWidth > { - - - {this.props.space && isReservedSpace(this.props.space) ? null : ( - - - - )} - + + + } helpText={i18n.translate( 'xpack.spaces.management.manageSpacePage.spaceDescriptionHelpText', { - defaultMessage: 'The description appears on the Space selection screen.', + defaultMessage: 'The description appears on the space selection screen.', } )} {...validator.validateSpaceDescription(this.props.space)} @@ -139,80 +123,97 @@ export class CustomizeSpace extends Component { - - - }> - - - + {editingExistingSpace ? null : ( + + } + helpText={ + } - closePopover={this.closePopover} - {...extraPopoverProps} - ownFocus={true} - isOpen={this.state.customizingAvatar} + {...this.props.validator.validateURLIdentifier(this.props.space)} + fullWidth > -
- -
-
-
+ + + )} +
+ + +

+ +

+ + } + description={ + <> +

+ {i18n.translate('xpack.spaces.management.manageSpacePage.avatarDescription', { + defaultMessage: 'Choose how your space avatar appears across Kibana.', + })} +

+ {space.avatarType === 'image' ? ( + }> + + + ) : ( + }> + + + )} + + } + fullWidth + > +
); } - public togglePopover = () => { - this.setState({ - customizingAvatar: !this.state.customizingAvatar, - }); - }; - - public closePopover = () => { - this.setState({ - customizingAvatar: false, - }); - }; - - public getPanelDescription = () => { - return this.props.editingExistingSpace ? ( -

- -

- ) : ( -

- -

- ); - }; - public onNameChange = (e: ChangeEvent) => { if (!this.props.space) { return; @@ -230,6 +231,12 @@ export class CustomizeSpace extends Component { ...this.props.space, name: e.target.value, id, + initials: this.props.space.customAvatarInitials + ? this.props.space.initials + : getSpaceInitials({ name: e.target.value }), + color: this.props.space.customAvatarColor + ? this.props.space.color + : getSpaceColor({ name: e.target.value }), }); }; @@ -240,7 +247,8 @@ export class CustomizeSpace extends Component { }); }; - public onSpaceIdentifierChange = (updatedIdentifier: string) => { + public onSpaceIdentifierChange = (e: ChangeEvent) => { + const updatedIdentifier = e.target.value; const usingCustomIdentifier = updatedIdentifier !== toSpaceIdentifier(this.props.space.name); this.setState({ @@ -252,7 +260,7 @@ export class CustomizeSpace extends Component { }); }; - public onAvatarChange = (space: Partial) => { + public onAvatarChange = (space: FormValues) => { this.props.onChange(space); }; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.test.tsx index babd89f69c784e..578da9b96611c4 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest'; +import { SpaceValidator } from '../../lib'; import { CustomizeSpaceAvatar } from './customize_space_avatar'; const space = { @@ -17,13 +18,19 @@ const space = { name: '', }; +const validator = new SpaceValidator({ shouldValidate: true }); + test('renders without crashing', () => { - const wrapper = shallowWithIntl(); + const wrapper = shallowWithIntl( + + ); expect(wrapper).toMatchSnapshot(); }); test('shows customization fields', () => { - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + ); expect(wrapper.find(EuiLink)).toHaveLength(0); expect(wrapper.find(EuiFieldText)).toHaveLength(2); // EuiColorPicker contains an EuiFieldText element @@ -41,17 +48,14 @@ test('invokes onChange callback when avatar is customized', () => { const changeHandler = jest.fn(); const wrapper = mountWithIntl( - + ); - wrapper - .find(EuiFieldText) - .first() - .find('input') - .simulate('change', { target: { value: 'NV' } }); + wrapper.find('input[name="spaceInitials"]').simulate('change', { target: { value: 'NV' } }); expect(changeHandler).toHaveBeenCalledWith({ ...customizedSpace, initials: 'NV', + customAvatarInitials: true, }); }); diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx index 96cd094c146458..827ef592459f74 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/customize_space/customize_space_avatar.tsx @@ -6,46 +6,30 @@ */ import { - EuiButton, + EuiButtonGroup, EuiColorPicker, EuiFieldText, EuiFilePicker, - EuiFlexItem, EuiFormRow, - EuiSpacer, - isValidHex, } from '@elastic/eui'; import type { ChangeEvent } from 'react'; import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; -import type { Space } from 'src/plugins/spaces_oss/common'; import { MAX_SPACE_INITIALS } from '../../../../common'; import { encode, imageTypes } from '../../../../common/lib/dataurl'; -import { getSpaceColor, getSpaceInitials } from '../../../space_avatar'; +import type { SpaceValidator } from '../../lib'; +import type { FormValues } from '../manage_space_page'; interface Props { - space: Partial; - onChange: (space: Partial) => void; + space: FormValues; + onChange: (space: FormValues) => void; + validator: SpaceValidator; } -interface State { - initialsHasFocus: boolean; - pendingInitials?: string | null; -} - -export class CustomizeSpaceAvatar extends Component { - private initialsRef: HTMLInputElement | null = null; - - constructor(props: Props) { - super(props); - this.state = { - initialsHasFocus: false, - }; - } - - private storeImageChanges(imageUrl: string) { +export class CustomizeSpaceAvatar extends Component { + private storeImageChanges(imageUrl: string | undefined) { this.props.onChange({ ...this.props.space, imageUrl, @@ -96,7 +80,10 @@ export class CustomizeSpaceAvatar extends Component { }; private onFileUpload = (files: FileList | null) => { - if (files == null) return; + if (files == null || files.length === 0) { + this.storeImageChanges(undefined); + return; + } const file = files[0]; if (imageTypes.indexOf(file.type) > -1) { encode(file).then((dataurl: string) => this.handleImageUpload(dataurl)); @@ -106,130 +93,117 @@ export class CustomizeSpaceAvatar extends Component { public render() { const { space } = this.props; - const { initialsHasFocus, pendingInitials } = this.state; - - const spaceColor = getSpaceColor(space); - const isInvalidSpaceColor = !isValidHex(spaceColor) && spaceColor !== ''; - return (
false}> - + this.props.onChange({ + ...space, + avatarType: avatarType as FormValues['avatarType'], + }) + } + buttonSize="m" /> - + {space.avatarType !== 'image' ? ( + + + + ) : ( + + + + )} - - {this.filePickerOrImage()} ); } - private removeImageUrl() { - this.props.onChange({ - ...this.props.space, - imageUrl: '', - }); - } - - public filePickerOrImage() { - if (!this.props.space.imageUrl) { - return ( - - - - ); - } else { - return ( - - this.removeImageUrl()} color="danger" iconType="trash"> - {i18n.translate('xpack.spaces.management.customizeSpaceAvatar.removeImage', { - defaultMessage: 'Remove custom image', - })} - - - ); - } - } - - public initialsInputRef = (ref: HTMLInputElement) => { - if (ref) { - this.initialsRef = ref; - this.initialsRef.addEventListener('focus', this.onInitialsFocus); - this.initialsRef.addEventListener('blur', this.onInitialsBlur); - } else { - if (this.initialsRef) { - this.initialsRef.removeEventListener('focus', this.onInitialsFocus); - this.initialsRef.removeEventListener('blur', this.onInitialsBlur); - this.initialsRef = null; - } - } - }; - - public onInitialsFocus = () => { - this.setState({ - initialsHasFocus: true, - pendingInitials: getSpaceInitials(this.props.space), - }); - }; - - public onInitialsBlur = () => { - this.setState({ - initialsHasFocus: false, - pendingInitials: null, - }); - }; - public onInitialsChange = (e: ChangeEvent) => { const initials = (e.target.value || '').substring(0, MAX_SPACE_INITIALS); - this.setState({ - pendingInitials: initials, - }); - this.props.onChange({ ...this.props.space, + customAvatarInitials: true, initials, }); }; @@ -237,6 +211,7 @@ export class CustomizeSpaceAvatar extends Component { public onColorChange = (color: string) => { this.props.onChange({ ...this.props.space, + customAvatarColor: true, color, }); }; diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.test.tsx deleted file mode 100644 index f28e17d0e1f034..00000000000000 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { shallowWithIntl } from '@kbn/test/jest'; - -import { SpaceValidator } from '../../lib'; -import { SpaceIdentifier } from './space_identifier'; - -test('renders without crashing', () => { - const props = { - space: { - id: '', - name: '', - }, - editable: true, - onChange: jest.fn(), - validator: new SpaceValidator(), - }; - const wrapper = shallowWithIntl( - - ); - expect(wrapper).toMatchSnapshot(); -}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.tsx b/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.tsx deleted file mode 100644 index bc863e4add2cc3..00000000000000 --- a/x-pack/plugins/spaces/public/management/edit_space/customize_space/space_identifier.tsx +++ /dev/null @@ -1,177 +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 { EuiFieldText, EuiFormRow, EuiLink } from '@elastic/eui'; -import type { ChangeEvent } from 'react'; -import React, { Component, Fragment } from 'react'; - -import type { InjectedIntl } from '@kbn/i18n/react'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; -import type { Space } from 'src/plugins/spaces_oss/common'; - -import type { SpaceValidator } from '../../lib'; -import { toSpaceIdentifier } from '../../lib'; - -interface Props { - space: Partial; - editable: boolean; - validator: SpaceValidator; - intl: InjectedIntl; - onChange: (updatedIdentifier: string) => void; -} - -interface State { - editing: boolean; -} - -class SpaceIdentifierUI extends Component { - private textFieldRef: HTMLInputElement | null = null; - - constructor(props: Props) { - super(props); - this.state = { - editing: false, - }; - } - - public render() { - const { intl } = this.props; - const { id = '' } = this.props.space; - - return ( - - - (this.textFieldRef = ref)} - fullWidth - /> - - - ); - } - - public getLabel = () => { - if (!this.props.editable) { - return ( -

- -

- ); - } - - const editLinkText = this.state.editing ? ( - - ) : ( - - ); - - const editLinkLabel = this.state.editing - ? this.props.intl.formatMessage({ - id: 'xpack.spaces.management.spaceIdentifier.resetSpaceNameLinkLabel', - defaultMessage: 'Reset the URL identifier', - }) - : this.props.intl.formatMessage({ - id: 'xpack.spaces.management.spaceIdentifier.customizeSpaceNameLinkLabel', - defaultMessage: 'Customize the URL identifier', - }); - - return ( -

- - - {editLinkText} - -

- ); - }; - - public getHelpText = ( - identifier: string = this.props.intl.formatMessage({ - id: 'xpack.spaces.management.spaceIdentifier.emptySpaceIdentifierText', - defaultMessage: 'awesome-space', - }) - ) => { - return ( -

- /s/{identifier}, - }} - /> -

- ); - }; - - public onEditClick = () => { - const currentlyEditing = this.state.editing; - if (currentlyEditing) { - // "Reset" clicked. Create space identifier based on the space name. - const resetIdentifier = toSpaceIdentifier(this.props.space.name); - - this.setState({ - editing: false, - }); - this.props.onChange(resetIdentifier); - } else { - this.setState( - { - editing: true, - }, - () => { - if (this.textFieldRef) { - this.textFieldRef.focus(); - } - } - ); - } - }; - - public onChange = (e: ChangeEvent) => { - if (!this.state.editing) { - return; - } - this.props.onChange(e.target.value); - }; -} - -export const SpaceIdentifier = injectI18n(SpaceIdentifierUI); diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap index c22a25ef60c313..b23b504bd0264a 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -3,34 +3,7 @@ exports[`EnabledFeatures renders as expected 1`] = ` - - - - - - - - - } + title="Features" > @@ -39,7 +12,7 @@ exports[`EnabledFeatures renders as expected 1`] = ` >

@@ -54,9 +27,17 @@ exports[`EnabledFeatures renders as expected 1`] = ` >

, + } + } />

diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx index 7f1ea57a6b89c5..af6c546fcf56a7 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx @@ -32,11 +32,9 @@ const features: KibanaFeatureConfig[] = [ ]; describe('EnabledFeatures', () => { - const getUrlForApp = (appId: string) => appId; - it(`renders as expected`, () => { expect( - shallowWithIntl( + shallowWithIntl( { disabledFeatures: ['feature-1', 'feature-2'], }} onChange={jest.fn()} - getUrlForApp={getUrlForApp} /> ) ).toMatchSnapshot(); @@ -63,7 +60,6 @@ describe('EnabledFeatures', () => { disabledFeatures: ['feature-1', 'feature-2'], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); @@ -97,7 +93,6 @@ describe('EnabledFeatures', () => { disabledFeatures: [], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); @@ -134,7 +129,6 @@ describe('EnabledFeatures', () => { disabledFeatures: [], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); @@ -164,7 +158,6 @@ describe('EnabledFeatures', () => { disabledFeatures: ['feature-1', 'feature-2'], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); @@ -192,7 +185,6 @@ describe('EnabledFeatures', () => { disabledFeatures: ['feature-1'], }} onChange={jest.fn()} - getUrlForApp={getUrlForApp} /> ); expect(findTestSubject(wrapper, 'hideAllFeaturesLink')).toHaveLength(1); @@ -211,7 +203,6 @@ describe('EnabledFeatures', () => { disabledFeatures: [], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); @@ -239,7 +230,6 @@ describe('EnabledFeatures', () => { disabledFeatures: [], }} onChange={changeHandler} - getUrlForApp={getUrlForApp} /> ); diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx index 667878df3254a5..7481676430307c 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx @@ -5,17 +5,16 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import type { ReactNode } from 'react'; -import React, { Component, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import type { ApplicationStart } from 'src/core/public'; import type { Space } from 'src/plugins/spaces_oss/common'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import type { KibanaFeatureConfig } from '../../../../../features/public'; -import { getEnabledFeatures } from '../../lib/feature_utils'; import { SectionPanel } from '../section_panel'; import { FeatureTable } from './feature_table'; @@ -23,117 +22,62 @@ interface Props { space: Partial; features: KibanaFeatureConfig[]; onChange: (space: Partial) => void; - getUrlForApp: ApplicationStart['getUrlForApp']; } -export class EnabledFeatures extends Component { - public render() { - const description = i18n.translate( - 'xpack.spaces.management.manageSpacePage.customizeVisibleFeatures', - { - defaultMessage: 'Customize visible features', - } - ); +export const EnabledFeatures: FunctionComponent = (props) => { + const { services } = useKibana(); + const canManageRoles = services.application?.capabilities.management?.security?.roles === true; - return ( - - - - -

- -

-
- - {this.getDescription()} -
- - - -
-
- ); - } - - private getPanelTitle = () => { - const featureCount = this.props.features.length; - const enabledCount = getEnabledFeatures(this.props.features, this.props.space).length; - - let details: null | ReactNode = null; - - if (enabledCount === featureCount) { - details = ( - - - - - - ); - } else if (enabledCount === 0) { - details = ( - - - - - - ); - } else { - details = ( - - - - - - ); - } - - return ( - - {' '} - {details} - - ); - }; - - private getDescription = () => { - return ( - - -

- -

-
-
- ); - }; -} + return ( + + + + +

+ +

+
+ + +

+ + + + ) : ( + + ), + }} + /> +

+
+
+ + + +
+
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.scss b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.scss deleted file mode 100644 index 35b9dc1d45661b..00000000000000 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.scss +++ /dev/null @@ -1,4 +0,0 @@ -.spcFeatureTableAccordionContent { - // Align accordion content with the feature category logo in the accordion's buttonContent - padding-left: $euiSizeXL; -} diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index 2c9eaf4563d05b..78ea73741a8adc 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -5,18 +5,16 @@ * 2.0. */ -import './feature_table.scss'; - import type { EuiCheckboxProps } from '@elastic/eui'; import { EuiAccordion, + EuiButtonEmpty, EuiCallOut, EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiIcon, - EuiLink, EuiSpacer, EuiText, EuiTitle, @@ -88,9 +86,9 @@ export class FeatureTable extends Component { const buttonContent = ( { if (!canExpandCategory) { const isChecked = enabledCount > 0; @@ -101,31 +99,28 @@ export class FeatureTable extends Component { } }} > - - - {category.euiIconType ? ( ) : null} - -

{category.label}

+ +

{category.label}

); const label: string = i18n.translate('xpack.spaces.management.featureAccordionSwitchLabel', { - defaultMessage: '{enabledCount} / {featureCount} features visible', + defaultMessage: '{enabledCount}/{featureCount} features visible', values: { enabledCount, featureCount, }, }); const extraAction = ( -