diff --git a/.backportrc.json b/.backportrc.json index 2603eb2e2d444fc..731f49183dba5c6 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -1,5 +1,30 @@ { "upstream": "elastic/kibana", - "branches": [{ "name": "7.x", "checked": true }, "7.7", "7.6", "7.5", "7.4", "7.3", "7.2", "7.1", "7.0", "6.8", "6.7", "6.6", "6.5", "6.4", "6.3", "6.2", "6.1", "6.0", "5.6"], - "labels": ["backport"] + "targetBranchChoices": [ + { "name": "master", "checked": true }, + { "name": "7.x", "checked": true }, + "7.7", + "7.6", + "7.5", + "7.4", + "7.3", + "7.2", + "7.1", + "7.0", + "6.8", + "6.7", + "6.6", + "6.5", + "6.4", + "6.3", + "6.2", + "6.1", + "6.0", + "5.6" + ], + "targetPRLabels": ["backport"], + "branchLabelMapping": { + "^v7.8.0$": "7.x", + "^v(\\d+).(\\d+).\\d+$": "$1.$2" + } } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3981a8e1e9afefb..a008fa7ea9239fc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,6 +3,7 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App +/x-pack/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app @@ -11,18 +12,21 @@ /src/legacy/core_plugins/kibana/public/discover/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/local_application_service/ @elastic/kibana-app /src/legacy/core_plugins/kibana/public/dev_tools/ @elastic/kibana-app -/src/plugins/vis_type_vislib/ @elastic/kibana-app -/src/plugins/vis_type_xy/ @elastic/kibana-app -/src/plugins/vis_type_table/ @elastic/kibana-app -/src/plugins/kibana_legacy/ @elastic/kibana-app -/src/plugins/vis_type_timelion/ @elastic/kibana-app /src/plugins/dashboard/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app /src/plugins/input_control_vis/ @elastic/kibana-app -/src/plugins/visualize/ @elastic/kibana-app -/src/plugins/vis_type_timeseries/ @elastic/kibana-app -/src/plugins/vis_type_metric/ @elastic/kibana-app +/src/plugins/kibana_legacy/ @elastic/kibana-app +/src/plugins/vis_default_editor/ @elastic/kibana-app /src/plugins/vis_type_markdown/ @elastic/kibana-app +/src/plugins/vis_type_metric/ @elastic/kibana-app +/src/plugins/vis_type_table/ @elastic/kibana-app +/src/plugins/vis_type_tagcloud/ @elastic/kibana-app +/src/plugins/vis_type_timelion/ @elastic/kibana-app +/src/plugins/vis_type_timeseries/ @elastic/kibana-app +/src/plugins/vis_type_vega/ @elastic/kibana-app +/src/plugins/vis_type_vislib/ @elastic/kibana-app +/src/plugins/vis_type_xy/ @elastic/kibana-app +/src/plugins/visualize/ @elastic/kibana-app # Core UI # Exclude tutorials folder for now because they are not owned by Kibana app and most will move out soon diff --git a/.i18nrc.json b/.i18nrc.json index b04c02f6b226554..be3c043b6e52f79 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -8,6 +8,7 @@ "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", "embeddableExamples": "examples/embeddable_examples", + "uiActionsExamples": "examples/ui_action_examples", "share": "src/plugins/share", "home": "src/plugins/home", "charts": "src/plugins/charts", diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 16aaf55802b1701..657e3ec8b8bb187 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -42,10 +42,10 @@ filters | metric "Average uptime" metricFont={ font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" - color={ - if {all {gte 0} {lt 0.8}} then="red" else="green" - } - align="center" lHeight=48 + color={ + if {all {gte 0} {lt 0.8}} then="red" else="green" + } + align="center" lHeight=48 } | render ---- @@ -324,12 +324,14 @@ case if={lte 50} then="green" ---- math "random()" | progress shape="gauge" label={formatnumber "0%"} - font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" - color={ - switch {case if={lte 0.5} then="green"} - {case if={all {gt 0.5} {lte 0.75}} then="orange"} - default="red" - }} + font={ + font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" + color={ + switch {case if={lte 0.5} then="green"} + {case if={all {gt 0.5} {lte 0.75}} then="orange"} + default="red" + } + } valueColor={ switch {case if={lte 0.5} then="green"} {case if={all {gt 0.5} {lte 0.75}} then="orange"} @@ -693,7 +695,25 @@ Alias: `value` [[demodata_fn]] === `demodata` -A mock data set that includes project CI times with usernames, countries, and run phases. +A sample data set that includes project CI times with usernames, countries, and run phases. + +*Expression syntax* +[source,js] +---- +demodata +demodata "ci" +demodata type="shirts" +---- + +*Code example* +[source,text] +---- +filters +| demodata +| table +| render +---- +`demodata` is a mock data set that you can use to start playing around in Canvas. *Accepts:* `filter` @@ -837,6 +857,28 @@ Alias: `value` Query Elasticsearch for the number of hits matching the specified query. +*Expression syntax* +[source,js] +---- +escount index="logstash-*" +escount "currency:"EUR"" index="kibana_sample_data_ecommerce" +escount query="response:404" index="kibana_sample_data_logs" +---- + +*Code example* +[source,text] +---- +filters +| escount "Cancelled:true" index="kibana_sample_data_flights" +| math "value" +| progress shape="semicircle" + label={formatnumber 0,0} + font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" color="#000000" align=center} + max={filters | escount index="kibana_sample_data_flights"} +| render +---- +The first `escount` expression retrieves the number of flights that were cancelled. The second `escount` expression retrieves the total number of flights. + *Accepts:* `filter` [cols="3*^<"] @@ -867,6 +909,34 @@ Default: `_all` Query Elasticsearch for raw documents. Specify the fields you want to retrieve, especially if you are asking for a lot of rows. +*Expression syntax* +[source,js] +---- +esdocs index="logstash-*" +esdocs "currency:"EUR"" index="kibana_sample_data_ecommerce" +esdocs query="response:404" index="kibana_sample_data_logs" +esdocs index="kibana_sample_data_flights" count=100 +esdocs index="kibana_sample_data_flights" sort="AvgTicketPrice, asc" +---- + +*Code example* +[source,text] +---- +filters +| esdocs index="kibana_sample_data_ecommerce" + fields="customer_gender, taxful_total_price, order_date" + sort="order_date, asc" + count=10000 +| mapColumn "order_date" + fn={getCell "order_date" | date {context} | rounddate "YYYY-MM-DD"} +| alterColumn "order_date" type="date" +| pointseries x="order_date" y="sum(taxful_total_price)" color="customer_gender" +| plot defaultStyle={seriesStyle lines=3} + palette={palette "#7ECAE3" "#003A4D" gradient=true} +| render +---- +This retrieves the first 10000 documents data from the `kibana_sample_data_ecommerce` index sorted by `order_date` in ascending order, and only requests the `customer_gender`, `taxful_total_price`, and `order_date` fields. + *Accepts:* `filter` [cols="3*^<"] @@ -915,6 +985,23 @@ Default: `_all` Queries Elasticsearch using Elasticsearch SQL. +*Expression syntax* +[source,js] +---- +essql query="SELECT * FROM "logstash*"" +essql "SELECT * FROM "apm*"" count=10000 +---- + +*Code example* +[source,text] +---- +filters +| essql query="SELECT Carrier, FlightDelayMin, AvgTicketPrice FROM "kibana_sample_data_flights"" +| table +| render +---- +This retrieves the `Carrier`, `FlightDelayMin`, and `AvgTicketPrice` fields from the "kibana_sample_data_flights" index. + *Accepts:* `filter` [cols="3*^<"] @@ -1107,7 +1194,7 @@ Default: `false` [[font_fn]] === `font` -Creates a font style. +Create a font style. *Expression syntax* [source,js] @@ -1244,7 +1331,7 @@ Alias: `format` [[formatnumber_fn]] === `formatnumber` -Formats a number into a formatted number string using the <>. +Formats a number into a formatted number string using the Numeral pattern. *Expression syntax* [source,js] @@ -1276,7 +1363,7 @@ The `formatnumber` subexpression receives the same `context` as the `progress` f Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `string` @@ -1559,6 +1646,34 @@ Alias: `value` [[m_fns]] == M +[float] +[[mapCenter_fn]] +=== `mapCenter` + +Returns an object with the center coordinates and zoom level of the map. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`lat` *** +|`number` +|Latitude for the center of the map + +|`lon` *** +|`number` +|Longitude for the center of the map + +|`zoom` *** +|`number` +|Zoom level of the map +|=== + +*Returns:* `mapCenter` + + [float] [[mapColumn_fn]] === `mapColumn` @@ -1612,6 +1727,12 @@ Default: `""` |The CSS font properties for the content. For example, "font-family" or "font-weight". Default: `${font}` + +|`openLinksInNewTab` +|`boolean` +|A true or false value for opening links in a new tab. The default value is `false`. Setting to `true` opens all links in a new tab. + +Default: `false` |=== *Returns:* `render` @@ -1675,7 +1796,7 @@ Default: `${font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" colo Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `render` @@ -2184,6 +2305,102 @@ Returns the number of rows. Pairs with <> to get the count of unique col [[s_fns]] == S +[float] +[[savedLens_fn]] +=== `savedLens` + +Returns an embeddable for a saved Lens visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`id` +|`string` +|The ID of the saved Lens visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the Lens visualization object +|=== + +*Returns:* `embeddable` + + +[float] +[[savedMap_fn]] +=== `savedMap` + +Returns an embeddable for a saved map object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`center` +|`mapCenter` +|The center and zoom level the map should have + +|`hideLayer` † +|`string` +|The IDs of map layers that should be hidden + +|`id` +|`string` +|The ID of the saved map object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the map +|=== + +*Returns:* `embeddable` + + +[float] +[[savedVisualization_fn]] +=== `savedVisualization` + +Returns an embeddable for a saved visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`colors` † +|`seriesStyle` +|Defines the color to use for a specific series + +|`hideLegend` +|`boolean` +|Specifies the option to hide the legend + +|`id` +|`string` +|The ID of the saved visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included +|=== + +*Returns:* `embeddable` + + [float] [[seriesStyle_fn]] === `seriesStyle` @@ -2579,6 +2796,30 @@ Default: `"now"` *Returns:* `datatable` +[float] +[[timerange_fn]] +=== `timerange` + +An object that represents a span of time. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`from` *** +|`string` +|The start of the time range + +|`to` *** +|`string` +|The end of the time range +|=== + +*Returns:* `timerange` + + [float] [[to_fn]] === `to` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index 7fd65e5db35f364..37142cf1794c322 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -49,6 +49,7 @@ esFilters: { generateFilters: typeof generateFilters; onlyDisabledFiltersChanged: (newFilters?: import("../common").Filter[] | undefined, oldFilters?: import("../common").Filter[] | undefined) => boolean; changeTimeFilter: typeof changeTimeFilter; + convertRangeFilterToTimeRangeString: typeof convertRangeFilterToTimeRangeString; mapAndFlattenFilters: (filters: import("../common").Filter[]) => import("../common").Filter[]; extractTimeFilter: typeof extractTimeFilter; } diff --git a/docs/logs/images/alert-actions-menu.png b/docs/logs/images/alert-actions-menu.png new file mode 100644 index 000000000000000..3f96a700a0ac129 Binary files /dev/null and b/docs/logs/images/alert-actions-menu.png differ diff --git a/docs/logs/images/alert-flyout.png b/docs/logs/images/alert-flyout.png new file mode 100644 index 000000000000000..30c8857758a8b4d Binary files /dev/null and b/docs/logs/images/alert-flyout.png differ diff --git a/docs/logs/index.asciidoc b/docs/logs/index.asciidoc index b12dc096bff4527..0d225e5e89c17e9 100644 --- a/docs/logs/index.asciidoc +++ b/docs/logs/index.asciidoc @@ -17,6 +17,7 @@ In this case, you will only see the logs for the selected component. * <> * <> * <> +* <> [role="screenshot"] image::logs/images/logs-console.png[Log Console in Kibana] @@ -30,3 +31,5 @@ include::using.asciidoc[] include::configuring.asciidoc[] include::log-rate.asciidoc[] + +include::logs-alerting.asciidoc[] diff --git a/docs/logs/logs-alerting.asciidoc b/docs/logs/logs-alerting.asciidoc new file mode 100644 index 000000000000000..f08a09187a0c815 --- /dev/null +++ b/docs/logs/logs-alerting.asciidoc @@ -0,0 +1,27 @@ +[role="xpack"] +[[xpack-logs-alerting]] +== Logs alerting + +[float] +=== Overview + +To use the alerting functionality you need to {kibana-ref}/alerting-getting-started.html#alerting-setup-prerequisites[set up alerting]. + +You can then select the *Create alert* option, from the *Alerts* actions dropdown. + +[role="screenshot"] +image::logs/images/alert-actions-menu.png[Screenshot showing alerts menu] + +Within the alert flyout you can configure your logs alert: + +[role="screenshot"] +image::logs/images/alert-flyout.png[Screenshot showing alerts flyout] + +[float] +=== Fields and comparators + +The comparators available for conditions depend on the chosen field. The combinations available are: + +- Numeric fields: *more than*, *more than or equals*, *less than*, *less than or equals*, *equals*, and *does not equal*. +- Aggregatable fields: *is* and *is not*. +- Non-aggregatable fields: *matches*, *does not match*, *matches phrase*, *does not match phrase*. diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index a5503969a3ec18a..85d580de9475f9a 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -70,3 +70,13 @@ This page has moved. Please see <>. == Maps This page has moved. Please see <>. + +[role="exclude",id="development-embedding-visualizations"] +== Embedding Visualizations + +This page was deleted. See <>. + +[role="exclude",id="development-create-visualization"] +== Developing Visualizations + +This page was deleted. See <>. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index d4dbe9407b7a909..547b4fdedcec69b 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -5,7 +5,7 @@ Alerting and action settings ++++ -Alerts and actions are enabled by default in {kib}, but require you configure the following in order to use them: +Alerts and actions are enabled by default in {kib}, but require you configure the following in order to use them: . <>. . <>. @@ -18,27 +18,36 @@ You can configure the following settings in the `kibana.yml` file. [[general-alert-action-settings]] ==== General settings -`xpack.encryptedSavedObjects.encryptionKey`:: +[cols="2*<"] +|=== -A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. -+ -If not set, {kib} will generate a random key on startup, but all alert and action functions will be blocked. Generated keys are not allowed for alerts and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerts and actions in high-availability deployments of {kib} will behave unexpectedly if the key isn't the same on all instances of {kib}. -+ -Although the key can be specified in clear text in `kibana.yml`, it's recommended to store this key securely in the <>. +| `xpack.encryptedSavedObjects.encryptionKey` + | A string of 32 or more characters used to encrypt sensitive properties on alerts and actions before they're stored in {es}. Third party credentials — such as the username and password used to connect to an SMTP service — are an example of encrypted properties. + + + + If not set, {kib} will generate a random key on startup, but all alert and action functions will be blocked. Generated keys are not allowed for alerts and actions because when a new key is generated on restart, existing encrypted data becomes inaccessible. For the same reason, alerts and actions in high-availability deployments of {kib} will behave unexpectedly if the key isn't the same on all instances of {kib}. + + + + Although the key can be specified in clear text in `kibana.yml`, it's recommended to store this key securely in the <>. + +|=== [float] [[action-settings]] ==== Action settings -`xpack.actions.whitelistedHosts`:: -A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. -+ -Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. +[cols="2*<"] +|=== + +| `xpack.actions.whitelistedHosts` + | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. + + + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. + +| `xpack.actions.enabledActionTypes` + | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + + + + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. -`xpack.actions.enabledActionTypes`:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. -+ -Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. +|=== [float] [[alert-settings]] diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index fd53c3aeb3605c7..8844fcd03ae9a2a 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -38,27 +38,42 @@ If you'd like to change any of the default values, copy and paste the relevant settings into your `kibana.yml` configuration file. Changing these settings may disable features of the APM App. -xpack.apm.enabled:: Set to `false` to disable the APM app. Defaults to `true`. +[cols="2*<"] +|=== +| `xpack.apm.enabled` + | Set to `false` to disable the APM app. Defaults to `true`. -xpack.apm.ui.enabled:: Set to `false` to hide the APM app from the menu. Defaults to `true`. +| `xpack.apm.ui.enabled` + | Set to `false` to hide the APM app from the menu. Defaults to `true`. -xpack.apm.ui.transactionGroupBucketSize:: Number of top transaction groups displayed in the APM app. Defaults to `100`. +| `xpack.apm.ui.transactionGroupBucketSize` + | Number of top transaction groups displayed in the APM app. Defaults to `100`. -xpack.apm.ui.maxTraceItems:: Maximum number of child items displayed when viewing trace details. Defaults to `1000`. +| `xpack.apm.ui.maxTraceItems` + | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -apm_oss.indexPattern:: The index pattern used for integrations with Machine Learning and Query Bar. -It must match all apm indices. Defaults to `apm-*`. +| `apm_oss.indexPattern` + | The index pattern used for integrations with Machine Learning and Query Bar. + It must match all apm indices. Defaults to `apm-*`. -apm_oss.errorIndices:: Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. +| `apm_oss.errorIndices` + | Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. -apm_oss.onboardingIndices:: Matcher for all onboarding indices. Defaults to `apm-*`. +| `apm_oss.onboardingIndices` + | Matcher for all onboarding indices. Defaults to `apm-*`. -apm_oss.spanIndices:: Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. +| `apm_oss.spanIndices` + | Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. -apm_oss.transactionIndices:: Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. +| `apm_oss.transactionIndices` + | Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. -apm_oss.metricsIndices:: Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. +| `apm_oss.metricsIndices` + | Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. -apm_oss.sourcemapIndices:: Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. +| `apm_oss.sourcemapIndices` + | Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. + +|=== // end::general-apm-settings[] diff --git a/docs/settings/dev-settings.asciidoc b/docs/settings/dev-settings.asciidoc index 436f169b82ca307..c43b96a8668e05f 100644 --- a/docs/settings/dev-settings.asciidoc +++ b/docs/settings/dev-settings.asciidoc @@ -12,12 +12,20 @@ They are enabled by default. [[grok-settings]] ==== Grok Debugger settings -`xpack.grokdebugger.enabled`:: -Set to `true` (default) to enable the <>. +[cols="2*<"] +|=== +| `xpack.grokdebugger.enabled` + | Set to `true` to enable the <>. Defaults to `true`. + +|=== [float] [[profiler-settings]] ==== {searchprofiler} Settings -`xpack.searchprofiler.enabled`:: -Set to `true` (default) to enable the <>. +[cols="2*<"] +|=== +| `xpack.searchprofiler.enabled` + | Set to `true` to enable the <>. Defaults to `true`. + +|=== diff --git a/docs/settings/general-infra-logs-ui-settings.asciidoc b/docs/settings/general-infra-logs-ui-settings.asciidoc index 7b32372a1f59a12..2a9d4df1ff43cf0 100644 --- a/docs/settings/general-infra-logs-ui-settings.asciidoc +++ b/docs/settings/general-infra-logs-ui-settings.asciidoc @@ -1,17 +1,30 @@ -`xpack.infra.enabled`:: Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. +[cols="2*<"] +|=== +| `xpack.infra.enabled` + | Set to `false` to disable the Logs and Metrics app plugin {kib}. Defaults to `true`. -`xpack.infra.sources.default.logAlias`:: Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +| `xpack.infra.sources.default.logAlias` + | Index pattern for matching indices that contain log data. Defaults to `filebeat-*,kibana_sample_data_logs*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -`xpack.infra.sources.default.metricAlias`:: Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. +| `xpack.infra.sources.default.metricAlias` + | Index pattern for matching indices that contain Metricbeat data. Defaults to `metricbeat-*`. To match multiple wildcard patterns, use a comma to separate the names, with no space after the comma. For example, `logstash-app1-*,default-logs-*`. -`xpack.infra.sources.default.fields.timestamp`:: Timestamp used to sort log entries. Defaults to `@timestamp`. +| `xpack.infra.sources.default.fields.timestamp` + | Timestamp used to sort log entries. Defaults to `@timestamp`. -`xpack.infra.sources.default.fields.message`:: Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. +| `xpack.infra.sources.default.fields.message` + | Fields used to display messages in the Logs app. Defaults to `['message', '@message']`. -`xpack.infra.sources.default.fields.tiebreaker`:: Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. +| `xpack.infra.sources.default.fields.tiebreaker` + | Field used to break ties between two entries with the same timestamp. Defaults to `_doc`. -`xpack.infra.sources.default.fields.host`:: Field used to identify hosts. Defaults to `host.name`. +| `xpack.infra.sources.default.fields.host` + | Field used to identify hosts. Defaults to `host.name`. -`xpack.infra.sources.default.fields.container`:: Field used to identify Docker containers. Defaults to `container.id`. +| `xpack.infra.sources.default.fields.container` + | Field used to identify Docker containers. Defaults to `container.id`. -`xpack.infra.sources.default.fields.pod`:: Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. +| `xpack.infra.sources.default.fields.pod` + | Field used to identify Kubernetes pods. Defaults to `kubernetes.pod.uid`. + +|=== diff --git a/docs/settings/graph-settings.asciidoc b/docs/settings/graph-settings.asciidoc index 7e597362b1cfc89..8ccff21a26f74a3 100644 --- a/docs/settings/graph-settings.asciidoc +++ b/docs/settings/graph-settings.asciidoc @@ -10,5 +10,10 @@ You do not need to configure any settings to use the {graph-features}. [float] [[general-graph-settings]] ==== General graph settings -`xpack.graph.enabled`:: -Set to `false` to disable the {graph-features}. + +[cols="2*<"] +|=== +| `xpack.graph.enabled` + | Set to `false` to disable the {graph-features}. + +|=== diff --git a/docs/settings/i18n-settings.asciidoc b/docs/settings/i18n-settings.asciidoc index 4fe466bcb45806f..6d92e74f17cb2b7 100644 --- a/docs/settings/i18n-settings.asciidoc +++ b/docs/settings/i18n-settings.asciidoc @@ -9,10 +9,7 @@ You do not need to configure any settings to run Kibana in English. ==== General i18n Settings `i18n.locale`:: -Kibana currently supports the following locales: -+ -- English - `en` (default) -- Chinese - `zh-CN` -- Japanese - `ja-JP` - - + {kib} supports the following locales: + * English - `en` (default) + * Chinese - `zh-CN` + * Japanese - `ja-JP` diff --git a/docs/settings/ml-settings.asciidoc b/docs/settings/ml-settings.asciidoc index 36578c909f51310..24e38e73bca9b78 100644 --- a/docs/settings/ml-settings.asciidoc +++ b/docs/settings/ml-settings.asciidoc @@ -11,12 +11,25 @@ enabled by default. [[general-ml-settings-kb]] ==== General {ml} settings -`xpack.ml.enabled`:: -Set to `true` (default) to enable {kib} {ml-features}. + -+ -If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} -instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, -you can still use the {ml} APIs. To disable {ml} entirely, see the -{ref}/ml-settings.html[{es} {ml} settings]. +[cols="2*<"] +|=== +| `xpack.ml.enabled` + | Set to `true` (default) to enable {kib} {ml-features}. + + + + If set to `false` in `kibana.yml`, the {ml} icon is hidden in this {kib} + instance. If `xpack.ml.enabled` is set to `true` in `elasticsearch.yml`, however, + you can still use the {ml} APIs. To disable {ml} entirely, see the + {ref}/ml-settings.html[{es} {ml} settings]. +|=== +[[data-visualizer-settings]] +==== {data-viz} settings + +[cols="2*<"] +|=== +| `xpack.ml.file_data_visualizer.max_file_size` + | Sets the file size limit when importing data in the {data-viz}. The default + value is `100MB`. The highest supported value for this setting is `1GB`. + +|=== diff --git a/docs/settings/monitoring-settings.asciidoc b/docs/settings/monitoring-settings.asciidoc index 6645f49029a5167..f180f2c3ecc973f 100644 --- a/docs/settings/monitoring-settings.asciidoc +++ b/docs/settings/monitoring-settings.asciidoc @@ -29,45 +29,49 @@ For more information, see [[monitoring-general-settings]] ==== General monitoring settings -`monitoring.enabled`:: -Set to `true` (default) to enable the {monitor-features} in {kib}. Unlike the -`monitoring.ui.enabled` setting, when this setting is `false`, the -monitoring back-end does not run and {kib} stats are not sent to the monitoring -cluster. - -`monitoring.ui.elasticsearch.hosts`:: -Specifies the location of the {es} cluster where your monitoring data is stored. -By default, this is the same as `elasticsearch.hosts`. This setting enables -you to use a single {kib} instance to search and visualize data in your -production cluster as well as monitor data sent to a dedicated monitoring -cluster. - -`monitoring.ui.elasticsearch.username`:: -Specifies the username used by {kib} monitoring to establish a persistent connection -in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} -monitoring cluster. - -Every other request performed by the Stack Monitoring UI to the monitoring {es} -cluster uses the authenticated user's credentials, which must be the same on -both the {es} monitoring cluster and the {es} production cluster. - -If not set, {kib} uses the value of the `elasticsearch.username` setting. - -`monitoring.ui.elasticsearch.password`:: -Specifies the password used by {kib} monitoring to establish a persistent connection -in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} -monitoring cluster. - -Every other request performed by the Stack Monitoring UI to the monitoring {es} -cluster uses the authenticated user's credentials, which must be the same on -both the {es} monitoring cluster and the {es} production cluster. - -If not set, {kib} uses the value of the `elasticsearch.password` setting. - -`monitoring.ui.elasticsearch.pingTimeout`:: -Specifies the time in milliseconds to wait for {es} to respond to internal -health checks. By default, it matches the `elasticsearch.pingTimeout` setting, -which has a default value of `30000`. +[cols="2*<"] +|=== +| `monitoring.enabled` + | Set to `true` (default) to enable the {monitor-features} in {kib}. Unlike the + `monitoring.ui.enabled` setting, when this setting is `false`, the + monitoring back-end does not run and {kib} stats are not sent to the monitoring + cluster. + +| `monitoring.ui.elasticsearch.hosts` + | Specifies the location of the {es} cluster where your monitoring data is stored. + By default, this is the same as `elasticsearch.hosts`. This setting enables + you to use a single {kib} instance to search and visualize data in your + production cluster as well as monitor data sent to a dedicated monitoring + cluster. + +| `monitoring.ui.elasticsearch.username` + | Specifies the username used by {kib} monitoring to establish a persistent connection + in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} + monitoring cluster. + + + + Every other request performed by the Stack Monitoring UI to the monitoring {es} + cluster uses the authenticated user's credentials, which must be the same on + both the {es} monitoring cluster and the {es} production cluster. + + + + If not set, {kib} uses the value of the `elasticsearch.username` setting. + +| `monitoring.ui.elasticsearch.password` + | Specifies the password used by {kib} monitoring to establish a persistent connection + in {kib} to the {es} monitoring cluster and to verify licensing status on the {es} + monitoring cluster. + + + + Every other request performed by the Stack Monitoring UI to the monitoring {es} + cluster uses the authenticated user's credentials, which must be the same on + both the {es} monitoring cluster and the {es} production cluster. + + + + If not set, {kib} uses the value of the `elasticsearch.password` setting. + +| `monitoring.ui.elasticsearch.pingTimeout` + | Specifies the time in milliseconds to wait for {es} to respond to internal + health checks. By default, it matches the `elasticsearch.pingTimeout` setting, + which has a default value of `30000`. + +|=== [float] [[monitoring-collection-settings]] @@ -75,15 +79,18 @@ which has a default value of `30000`. These settings control how data is collected from {kib}. -`monitoring.kibana.collection.enabled`:: -Set to `true` (default) to enable data collection from the {kib} NodeJS server -for {kib} Dashboards to be featured in the Monitoring. +[cols="2*<"] +|=== +| `monitoring.kibana.collection.enabled` + | Set to `true` (default) to enable data collection from the {kib} NodeJS server + for {kib} Dashboards to be featured in the Monitoring. -`monitoring.kibana.collection.interval`:: -Specifies the number of milliseconds to wait in between data sampling on the -{kib} NodeJS server for the metrics that are displayed in the {kib} dashboards. -Defaults to `10000` (10 seconds). +| `monitoring.kibana.collection.interval` + | Specifies the number of milliseconds to wait in between data sampling on the + {kib} NodeJS server for the metrics that are displayed in the {kib} dashboards. + Defaults to `10000` (10 seconds). +|=== [float] [[monitoring-ui-settings]] @@ -94,27 +101,31 @@ However, the defaults work best in most circumstances. For more information about configuring {kib}, see {kibana-ref}/settings.html[Setting Kibana Server Properties]. -`monitoring.ui.elasticsearch.logFetchCount`:: -Specifies the number of log entries to display in the Monitoring UI. Defaults to -`10`. The maximum value is `50`. +[cols="2*<"] +|=== +| `monitoring.ui.elasticsearch.logFetchCount` + | Specifies the number of log entries to display in the Monitoring UI. Defaults to + `10`. The maximum value is `50`. -`monitoring.ui.max_bucket_size`:: -Specifies the number of term buckets to return out of the overall terms list when -performing terms aggregations to retrieve index and node metrics. For more -information about the `size` parameter, see -{ref}/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size[Terms Aggregation]. -Defaults to `10000`. +| `monitoring.ui.max_bucket_size` + | Specifies the number of term buckets to return out of the overall terms list when + performing terms aggregations to retrieve index and node metrics. For more + information about the `size` parameter, see + {ref}/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-size[Terms Aggregation]. + Defaults to `10000`. -`monitoring.ui.min_interval_seconds`:: -Specifies the minimum number of seconds that a time bucket in a chart can -represent. Defaults to 10. If you modify the -`monitoring.ui.collection.interval` in `elasticsearch.yml`, use the same -value in this setting. +| `monitoring.ui.min_interval_seconds` + | Specifies the minimum number of seconds that a time bucket in a chart can + represent. Defaults to 10. If you modify the + `monitoring.ui.collection.interval` in `elasticsearch.yml`, use the same + value in this setting. -`monitoring.ui.enabled`:: -Set to `false` to hide the Monitoring UI in {kib}. The monitoring back-end -continues to run as an agent for sending {kib} stats to the monitoring -cluster. Defaults to `true`. +| `monitoring.ui.enabled` + | Set to `false` to hide the Monitoring UI in {kib}. The monitoring back-end + continues to run as an agent for sending {kib} stats to the monitoring + cluster. Defaults to `true`. + +|=== [float] [[monitoring-ui-cgroup-settings]] @@ -125,18 +136,20 @@ better decisions about your container performance, rather than guessing based on the overall machine performance. If you are not running your applications in a container, then Cgroup statistics are not useful. -`monitoring.ui.container.elasticsearch.enabled`:: - -For {es} clusters that are running in containers, this setting changes the -*Node Listing* to display the CPU utilization based on the reported Cgroup -statistics. It also adds the calculated Cgroup CPU utilization to the -*Node Overview* page instead of the overall operating system's CPU -utilization. Defaults to `false`. - -`monitoring.ui.container.logstash.enabled`:: - -For {ls} nodes that are running in containers, this setting -changes the {ls} *Node Listing* to display the CPU utilization -based on the reported Cgroup statistics. It also adds the -calculated Cgroup CPU utilization to the {ls} node detail -pages instead of the overall operating system’s CPU utilization. Defaults to `false`. +[cols="2*<"] +|=== +| `monitoring.ui.container.elasticsearch.enabled` + | For {es} clusters that are running in containers, this setting changes the + *Node Listing* to display the CPU utilization based on the reported Cgroup + statistics. It also adds the calculated Cgroup CPU utilization to the + *Node Overview* page instead of the overall operating system's CPU + utilization. Defaults to `false`. + +| `monitoring.ui.container.logstash.enabled` + | For {ls} nodes that are running in containers, this setting + changes the {ls} *Node Listing* to display the CPU utilization + based on the reported Cgroup statistics. It also adds the + calculated Cgroup CPU utilization to the {ls} node detail + pages instead of the overall operating system’s CPU utilization. Defaults to `false`. + +|=== diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 9a45fb9ab1d0c9b..7c50dbf542d0d6c 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -14,45 +14,54 @@ You can configure `xpack.reporting` settings in your `kibana.yml` to: [float] [[general-reporting-settings]] ==== General reporting settings -[[xpack-enable-reporting]]`xpack.reporting.enabled`:: -Set to `false` to disable the {report-features}. -`xpack.reporting.encryptionKey`:: -Set to any text string. By default, Kibana will generate a random key when it -starts, which will cause pending reports to fail after restart. Configure this -setting to preserve the same key across multiple restarts and multiple instances of Kibana. +[cols="2*<"] +|=== +| [[xpack-enable-reporting]]`xpack.reporting.enabled` + | Set to `false` to disable the {report-features}. + +| `xpack.reporting.encryptionKey` + | Set to any text string. By default, {kib} will generate a random key when it + starts, which will cause pending reports to fail after restart. Configure this + setting to preserve the same key across multiple restarts and multiple instances of {kib}. + +|=== [float] [[reporting-kibana-server-settings]] -==== Kibana server settings +==== {kib} server settings -Reporting opens the {kib} web interface in a server process to generate -screenshots of {kib} visualizations. In most cases, the default settings -will work and you don't need to configure Reporting to communicate with {kib}. +Reporting opens the {kib} web interface in a server process to generate +screenshots of {kib} visualizations. In most cases, the default settings +will work and you don't need to configure Reporting to communicate with {kib}. However, if your client connections must go through a reverse-proxy -to access {kib}, Reporting configuration must have the proxy port, protocol, +to access {kib}, Reporting configuration must have the proxy port, protocol, and hostname set in the `xpack.reporting.kibanaServer.*` settings. -[NOTE] +[NOTE] ==== -If a reverse-proxy carries encrypted traffic from end-user -clients back to a {kib} server, the proxy port, protocol, and hostname -in Reporting settings must be valid for the encryption that the Reporting -browser will receive. Encrypted communications will fail if there are +If a reverse-proxy carries encrypted traffic from end-user +clients back to a {kib} server, the proxy port, protocol, and hostname +in Reporting settings must be valid for the encryption that the Reporting +browser will receive. Encrypted communications will fail if there are mismatches in the host information between the request and the certificate on the server. Configuring the `xpack.reporting.kibanaServer` settings to point to a -proxy host requires that the Kibana server has network access to the proxy. +proxy host requires that the {kib} server has network access to the proxy. ==== -`xpack.reporting.kibanaServer.port`:: -The port for accessing Kibana, if different from the `server.port` value. +[cols="2*<"] +|=== +| `xpack.reporting.kibanaServer.port` + | The port for accessing {kib}, if different from the `server.port` value. + +| `xpack.reporting.kibanaServer.protocol` + | The protocol for accessing {kib}, typically `http` or `https`. -`xpack.reporting.kibanaServer.protocol`:: -The protocol for accessing Kibana, typically `http` or `https`. +| `xpack.reporting.kibanaServer.hostname` + | The hostname for accessing {kib}, if different from the `server.host` value. -`xpack.reporting.kibanaServer.hostname`:: -The hostname for accessing {kib}, if different from the `server.host` value. +|=== [NOTE] ============ @@ -68,55 +77,67 @@ because, in the Reporting browser, it becomes an automatic redirect to `"0.0.0.0 ==== Background job settings Reporting generates reports in the background and jobs are coordinated using documents -in Elasticsearch. Depending on how often you generate reports and the overall number of +in {es}. Depending on how often you generate reports and the overall number of reports, you might need to change the following settings. -`xpack.reporting.queue.indexInterval`:: -How often the index that stores reporting jobs rolls over to a new index. -Valid values are `year`, `month`, `week`, `day`, and `hour`. Defaults to `week`. +[cols="2*<"] +|=== +| `xpack.reporting.queue.indexInterval` + | How often the index that stores reporting jobs rolls over to a new index. + Valid values are `year`, `month`, `week`, `day`, and `hour`. Defaults to `week`. -`xpack.reporting.queue.pollEnabled`:: -Set to `true` (default) to enable the Kibana instance to to poll the index for -pending jobs and claim them for execution. Setting this to `false` allows the -Kibana instance to only add new jobs to the reporting queue, list jobs, and -provide the downloads to completed report through the UI. +| `xpack.reporting.queue.pollEnabled` + | Set to `true` (default) to enable the {kib} instance to to poll the index for + pending jobs and claim them for execution. Setting this to `false` allows the + {kib} instance to only add new jobs to the reporting queue, list jobs, and + provide the downloads to completed report through the UI. + +|=== [NOTE] ============ -Running multiple instances of Kibana in a cluster for load balancing of +Running multiple instances of {kib} in a cluster for load balancing of reporting requires identical values for `xpack.reporting.encryptionKey` and, if security is enabled, `xpack.security.encryptionKey`. ============ -`xpack.reporting.queue.pollInterval`:: -Specifies the number of milliseconds that the reporting poller waits between polling the -index for any pending Reporting jobs. Defaults to `3000` (3 seconds). +[cols="2*<"] +|=== +| `xpack.reporting.queue.pollInterval` + | Specifies the number of milliseconds that the reporting poller waits between polling the + index for any pending Reporting jobs. Defaults to `3000` (3 seconds). + +| [[xpack-reporting-q-timeout]] `xpack.reporting.queue.timeout` + | How long each worker has to produce a report. If your machine is slow or under + heavy load, you might need to increase this timeout. Specified in milliseconds. + If a Reporting job execution time goes over this time limit, the job will be + marked as a failure and there will not be a download available. + Defaults to `120000` (two minutes). -[[xpack-reporting-q-timeout]]`xpack.reporting.queue.timeout`:: -How long each worker has to produce a report. If your machine is slow or under -heavy load, you might need to increase this timeout. Specified in milliseconds. -If a Reporting job execution time goes over this time limit, the job will be -marked as a failure and there will not be a download available. -Defaults to `120000` (two minutes). +|=== [float] [[reporting-capture-settings]] ==== Capture settings -Reporting works by capturing screenshots from Kibana. The following settings +Reporting works by capturing screenshots from {kib}. The following settings control the capturing process. -`xpack.reporting.capture.timeouts.openUrl`:: -How long to allow the Reporting browser to wait for the initial data of the -Kibana page to load. Defaults to `30000` (30 seconds). +[cols="2*<"] +|=== +| `xpack.reporting.capture.timeouts.openUrl` + | How long to allow the Reporting browser to wait for the initial data of the + {kib} page to load. Defaults to `30000` (30 seconds). + +| `xpack.reporting.capture.timeouts.waitForElements` + | How long to allow the Reporting browser to wait for the visualization panels to + load on the {kib} page. Defaults to `30000` (30 seconds). -`xpack.reporting.capture.timeouts.waitForElements`:: -How long to allow the Reporting browser to wait for the visualization panels to -load on the Kibana page. Defaults to `30000` (30 seconds). +| `xpack.reporting.capture.timeouts.renderComplete` + | How long to allow the Reporting browser to wait for each visualization to + signal that it is done renderings. Defaults to `30000` (30 seconds). -`xpack.reporting.capture.timeouts.renderComplete`:: -How long to allow the Reporting brwoser to wait for each visualization to -signal that it is done renderings. Defaults to `30000` (30 seconds). +|=== [NOTE] ============ @@ -126,20 +147,24 @@ capturing the page with a screenshot. As a result, a download will be available, but there will likely be errors in the visualizations in the report. ============ -`xpack.reporting.capture.maxAttempts`:: -If capturing a report fails for any reason, Kibana will re-attempt othe reporting -job, as many times as this setting. Defaults to `3`. +[cols="2*<"] +|=== +| `xpack.reporting.capture.maxAttempts` + | If capturing a report fails for any reason, {kib} will re-attempt other reporting + job, as many times as this setting. Defaults to `3`. -`xpack.reporting.capture.loadDelay`:: -When visualizations are not evented, this is the amount of time before -taking a screenshot. All visualizations that ship with Kibana are evented, so this -setting should not have much effect. If you are seeing empty images instead of -visualizations, try increasing this value. -Defaults to `3000` (3 seconds). +| `xpack.reporting.capture.loadDelay` + | When visualizations are not evented, this is the amount of time before + taking a screenshot. All visualizations that ship with {kib} are evented, so this + setting should not have much effect. If you are seeing empty images instead of + visualizations, try increasing this value. + Defaults to `3000` (3 seconds). -[[xpack-reporting-browser]]`xpack.reporting.capture.browser.type`:: -Specifies the browser to use to capture screenshots. This setting exists for -backward compatibility. The only valid option is `chromium`. +| [[xpack-reporting-browser]] `xpack.reporting.capture.browser.type` + | Specifies the browser to use to capture screenshots. This setting exists for + backward compatibility. The only valid option is `chromium`. + +|=== [float] [[reporting-chromium-settings]] @@ -147,47 +172,59 @@ backward compatibility. The only valid option is `chromium`. When `xpack.reporting.capture.browser.type` is set to `chromium` (default) you can also specify the following settings. -`xpack.reporting.capture.browser.chromium.disableSandbox`:: -Elastic recommends that you research the feasibility of enabling unprivileged user namespaces. -See Chromium Sandbox for additional information. Defaults to false for all operating systems except Debian, -Red Hat Linux, and CentOS which use true +[cols="2*<"] +|=== +| `xpack.reporting.capture.browser.chromium.disableSandbox` + | It is recommended that you research the feasibility of enabling unprivileged user namespaces. + See Chromium Sandbox for additional information. Defaults to false for all operating systems except Debian, + Red Hat Linux, and CentOS which use true. -`xpack.reporting.capture.browser.chromium.proxy.enabled`:: -Enables the proxy for Chromium to use. When set to `true`, you must also specify the -`xpack.reporting.capture.browser.chromium.proxy.server` setting. -Defaults to `false` +| `xpack.reporting.capture.browser.chromium.proxy.enabled` + | Enables the proxy for Chromium to use. When set to `true`, you must also specify the + `xpack.reporting.capture.browser.chromium.proxy.server` setting. + Defaults to `false`. -`xpack.reporting.capture.browser.chromium.proxy.server`:: -The uri for the proxy server. Providing the username and password for the proxy server via the uri is not supported. +| `xpack.reporting.capture.browser.chromium.proxy.server` + | The uri for the proxy server. Providing the username and password for the proxy server via the uri is not supported. -`xpack.reporting.capture.browser.chromium.proxy.bypass`:: -An array of hosts that should not go through the proxy server and should use a direct connection instead. -Examples of valid entries are "elastic.co", "*.elastic.co", ".elastic.co", ".elastic.co:5601" +| `xpack.reporting.capture.browser.chromium.proxy.bypass` + | An array of hosts that should not go through the proxy server and should use a direct connection instead. + Examples of valid entries are "elastic.co", "*.elastic.co", ".elastic.co", ".elastic.co:5601". +|=== [float] [[reporting-csv-settings]] ==== CSV settings -[[xpack-reporting-csv]]`xpack.reporting.csv.maxSizeBytes`:: -The maximum size of a CSV file before being truncated. This setting exists to prevent -large exports from causing performance and storage issues. -Defaults to `10485760` (10mB) + +[cols="2*<"] +|=== +| [[xpack-reporting-csv]] `xpack.reporting.csv.maxSizeBytes` + | The maximum size of a CSV file before being truncated. This setting exists to prevent + large exports from causing performance and storage issues. + Defaults to `10485760` (10mB). + +|=== [float] [[reporting-advanced-settings]] ==== Advanced settings -`xpack.reporting.index`:: -Reporting uses a weekly index in Elasticsearch to store the reporting job and -the report content. The index is automatically created if it does not already -exist. Configure this to a unique value, beginning with `.reporting-`, for every -Kibana instance that has a unique `kibana.index` setting. Defaults to `.reporting` +[cols="2*<"] +|=== +| `xpack.reporting.index` + | Reporting uses a weekly index in {es} to store the reporting job and + the report content. The index is automatically created if it does not already + exist. Configure this to a unique value, beginning with `.reporting-`, for every + {kib} instance that has a unique `kibana.index` setting. Defaults to `.reporting`. + +| `xpack.reporting.roles.allow` + | Specifies the roles in addition to superusers that can use reporting. + Defaults to `[ "reporting_user" ]`. + -`xpack.reporting.roles.allow`:: -Specifies the roles in addition to superusers that can use reporting. -Defaults to `[ "reporting_user" ]` -+ --- -NOTE: Each user has access to only their own reports. +|=== --- +[NOTE] +============ +Each user has access to only their own reports. +============ diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 16d68a7759f776f..8f6905d64313910 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -12,55 +12,83 @@ You do not need to configure any additional settings to use the [[general-security-settings]] ==== General security settings -`xpack.security.enabled`:: -By default, {kib} automatically detects whether to enable the -{security-features} based on the license and whether {es} {security-features} -are enabled. -+ -Do not set this to `false`; it disables the login form, user and role management -screens, and authorization using <>. To disable -{security-features} entirely, see -{ref}/security-settings.html[{es} security settings]. - -`xpack.security.audit.enabled`:: -Set to `true` to enable audit logging for security events. By default, it is set -to `false`. For more details see <>. +[cols="2*<"] +|=== +| `xpack.security.enabled` + | By default, {kib} automatically detects whether to enable the + {security-features} based on the license and whether {es} {security-features} + are enabled. + + + + Do not set this to `false`; it disables the login form, user and role management + screens, and authorization using <>. To disable + {security-features} entirely, see + {ref}/security-settings.html[{es} security settings]. + +| `xpack.security.audit.enabled` + | Set to `true` to enable audit logging for security events. By default, it is set + to `false`. For more details see <>. + +|=== [float] [[security-ui-settings]] ==== User interface security settings -You can configure the following settings in the `kibana.yml` file: - -`xpack.security.cookieName`:: -Sets the name of the cookie used for the session. The default value is `"sid"`. - -`xpack.security.encryptionKey`:: -An arbitrary string of 32 characters or more that is used to encrypt credentials -in a cookie. It is crucial that this key is not exposed to users of {kib}. By -default, a value is automatically generated in memory. If you use that default -behavior, all sessions are invalidated when {kib} restarts. -In addition, high-availability deployments of {kib} will behave unexpectedly -if this setting isn't the same for all instances of {kib}. - -`xpack.security.secureCookies`:: -Sets the `secure` flag of the session cookie. The default value is `false`. It -is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set -this to `true` if SSL is configured outside of {kib} (for example, you are -routing requests through a load balancer or proxy). - -`xpack.security.session.idleTimeout`:: -Sets the session duration. The format is a string of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). By default, sessions stay active until the -browser is closed. When this is set to an explicit idle timeout, closing the -browser still requires the user to log back in to {kib}. - -`xpack.security.session.lifespan`:: -Sets the maximum duration, also known as "absolute timeout". The format is a -string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). By default, -a session can be renewed indefinitely. When this value is set, a session will end -once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` -is not set, this setting will still cause sessions to expire. - -`xpack.security.loginAssistanceMessage`:: -Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. +You can configure the following settings in the `kibana.yml` file. + +[cols="2*<"] +|=== +| `xpack.security.cookieName` + | Sets the name of the cookie used for the session. The default value is `"sid"`. + +| `xpack.security.encryptionKey` + | An arbitrary string of 32 characters or more that is used to encrypt credentials + in a cookie. It is crucial that this key is not exposed to users of {kib}. By + default, a value is automatically generated in memory. If you use that default + behavior, all sessions are invalidated when {kib} restarts. + In addition, high-availability deployments of {kib} will behave unexpectedly + if this setting isn't the same for all instances of {kib}. + +| `xpack.security.secureCookies` + | Sets the `secure` flag of the session cookie. The default value is `false`. It + is automatically set to `true` if `server.ssl.enabled` is set to `true`. Set + this to `true` if SSL is configured outside of {kib} (for example, you are + routing requests through a load balancer or proxy). + +| `xpack.security.session.idleTimeout` + | Sets the session duration. By default, sessions stay active until the + browser is closed. When this is set to an explicit idle timeout, closing the + browser still requires the user to log back in to {kib}. + +|=== + +[TIP] +============ +The format is a string of `[ms|s|m|h|d|w|M|Y]` +(e.g. '70ms', '5s', '3d', '1Y'). +============ + +[cols="2*<"] +|=== + +| `xpack.security.session.lifespan` + | Sets the maximum duration, also known as "absolute timeout". By default, + a session can be renewed indefinitely. When this value is set, a session will end + once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` + is not set, this setting will still cause sessions to expire. + +|=== + +[TIP] +============ +The format is a +string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +============ + +[cols="2*<"] +|=== + +| `xpack.security.loginAssistanceMessage` + | Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. + +|=== diff --git a/docs/settings/spaces-settings.asciidoc b/docs/settings/spaces-settings.asciidoc index bb0a15b29a087f9..bda5f00f762cdc7 100644 --- a/docs/settings/spaces-settings.asciidoc +++ b/docs/settings/spaces-settings.asciidoc @@ -5,18 +5,22 @@ Spaces settings ++++ -By default, Spaces is enabled in Kibana, and you can secure Spaces using +By default, Spaces is enabled in Kibana, and you can secure Spaces using roles when Security is enabled. [float] [[spaces-settings]] ==== Spaces settings -`xpack.spaces.enabled`:: -Set to `true` (default) to enable Spaces in {kib}. +[cols="2*<"] +|=== +| `xpack.spaces.enabled` + | Set to `true` (default) to enable Spaces in {kib}. -`xpack.spaces.maxSpaces`:: -The maximum amount of Spaces that can be used with this instance of Kibana. Some operations -in Kibana return all spaces using a single `_search` from Elasticsearch, so this must be -set lower than the `index.max_result_window` in Elasticsearch. -Defaults to `1000`. \ No newline at end of file +| `xpack.spaces.maxSpaces` + | The maximum amount of Spaces that can be used with this instance of {kib}. Some operations + in {kib} return all spaces using a single `_search` from {es}, so this must be + set lower than the `index.max_result_window` in {es}. + Defaults to `1000`. + +|=== diff --git a/docs/settings/telemetry-settings.asciidoc b/docs/settings/telemetry-settings.asciidoc index ad5f53ad879f8fa..33f167b13b31090 100644 --- a/docs/settings/telemetry-settings.asciidoc +++ b/docs/settings/telemetry-settings.asciidoc @@ -8,7 +8,7 @@ By default, Usage Collection (also known as Telemetry) is enabled. This helps us learn about the {kib} features that our users are most interested in, so we can focus our efforts on making them even better. -You can control whether this data is sent from the {kib} servers, or if it should be sent +You can control whether this data is sent from the {kib} servers, or if it should be sent from the user's browser, in case a firewall is blocking the connections from the server. Additionally, you can decide to completely disable this feature either in the config file or in {kib} via *Management > Kibana > Advanced Settings > Usage Data*. See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to learn more. @@ -17,22 +17,30 @@ See our https://www.elastic.co/legal/privacy-statement[Privacy Statement] to lea [[telemetry-general-settings]] ==== General telemetry settings -`telemetry.enabled`:: *Default: true*. -Set to `true` to send cluster statistics to Elastic. Reporting your -cluster statistics helps us improve your user experience. Your data is never -shared with anyone. Set to `false` to disable statistics reporting from any -browser connected to the {kib} instance. - -`telemetry.sendUsageFrom`:: *Default: 'browser'*. -Set to `'server'` to report the cluster statistics from the {kib} server. -If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes -it is behind a firewall and falls back to `'browser'` to send it from users' browsers -when they are navigating through {kib}. - -`telemetry.optIn`:: *Default: true*. -Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through -*Advanced Settings* in {kib}. - -`telemetry.allowChangingOptInStatus`:: *Default: true*. -Set to `true` to allow overwriting the `telemetry.optIn` setting via the {kib} UI. -Note: When `false`, `telemetry.optIn` must be `true`. To disable telemetry and not allow users to change that parameter, use `telemetry.enabled`. +[cols="2*<"] +|=== +| `telemetry.enabled` + | Set to `true` to send cluster statistics to Elastic. Reporting your + cluster statistics helps us improve your user experience. Your data is never + shared with anyone. Set to `false` to disable statistics reporting from any + browser connected to the {kib} instance. Defaults to `true`. + +| `telemetry.sendUsageFrom` + | Set to `'server'` to report the cluster statistics from the {kib} server. + If the server fails to connect to our endpoint at https://telemetry.elastic.co/, it assumes + it is behind a firewall and falls back to `'browser'` to send it from users' browsers + when they are navigating through {kib}. Defaults to 'browser'. + +| `telemetry.optIn` + | Set to `true` to automatically opt into reporting cluster statistics. You can also opt out through + *Advanced Settings* in {kib}. Defaults to `true`. + +| `telemetry.allowChangingOptInStatus` + | Set to `true` to allow overwriting the `telemetry.optIn` setting via the {kib} UI. Defaults to `true`. + + +|=== + +[NOTE] +============ +When `false`, `telemetry.optIn` must be `true`. To disable telemetry and not allow users to change that parameter, use `telemetry.enabled`. +============ diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 41fe8d337c03b76..cc662af08b8f192 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -1,7 +1,7 @@ [[settings]] -== Configuring Kibana +== Configuring {kib} -The Kibana server reads properties from the `kibana.yml` file on startup. The +The {kib} server reads properties from the `kibana.yml` file on startup. The location of this file differs depending on how you installed {kib}. For example, if you installed {kib} from an archive distribution (`.tar.gz` or `.zip`), by default it is in `$KIBANA_HOME/config`. By default, with package distributions @@ -11,444 +11,622 @@ The default host and port settings configure {kib} to run on `localhost:5601`. T variety of other options. Finally, environment variables can be injected into configuration using `${MY_ENV_VAR}` syntax. -.Kibana configuration settings +[cols="2*<"] +|=== -`console.enabled:`:: *Default: true* Set to false to disable Console. Toggling -this will cause the server to regenerate assets on the next startup, which may -cause a delay before pages start being served. +| `console.enabled:` + | Toggling this causes the server to regenerate assets on the next startup, +which may cause a delay before pages start being served. +Set to `false` to disable Console. *Default: `true`* -`cpu.cgroup.path.override:`:: Override for cgroup cpu path when mounted in a -manner that is inconsistent with `/proc/self/cgroup` +| `cpu.cgroup.path.override:` + | Override for cgroup cpu path when mounted in a +manner that is inconsistent with `/proc/self/cgroup`. -`cpuacct.cgroup.path.override:`:: Override for cgroup cpuacct path when mounted -in a manner that is inconsistent with `/proc/self/cgroup` +| `cpuacct.cgroup.path.override:` + | Override for cgroup cpuacct path when mounted +in a manner that is inconsistent with `/proc/self/cgroup`. -`csp.rules:`:: A template -https://w3c.github.io/webappsec-csp/[content-security-policy] that disables -certain unnecessary and potentially insecure capabilities in the browser. We -strongly recommend that you keep the default CSP rules that ship with Kibana. +| `csp.rules:` + | A https://w3c.github.io/webappsec-csp/[content-security-policy] template +that disables certain unnecessary and potentially insecure capabilities in +the browser. It is strongly recommended that you keep the default CSP rules +that ship with {kib}. -`csp.strict:`:: *Default: `true`* Blocks access to Kibana to any browser that -does not enforce even rudimentary CSP rules. In practice, this will disable +| `csp.strict:` + | Blocks {kib} access to any browser that +does not enforce even rudimentary CSP rules. In practice, this disables support for older, less safe browsers like Internet Explorer. -See <> for more information. - -`csp.warnLegacyBrowsers:`:: *Default: `true`* Shows a warning message after -loading Kibana to any browser that does not enforce even rudimentary CSP rules, -though Kibana is still accessible. This configuration is effectively ignored -when `csp.strict` is enabled. - -`elasticsearch.customHeaders:`:: *Default: `{}`* Header names and values to send -to Elasticsearch. Any custom headers cannot be overwritten by client-side -headers, regardless of the `elasticsearch.requestHeadersWhitelist` configuration. - -`elasticsearch.hosts:`:: *Default: `[ "http://localhost:9200" ]`* The URLs of the {es} instances to use for all your queries. All nodes -listed here must be on the same cluster. +For more information, refer to <>. +*Default: `true`* + +| `csp.warnLegacyBrowsers:` + | Shows a warning message after loading {kib} to any browser that does not +enforce even rudimentary CSP rules, though {kib} is still accessible. This +configuration is effectively ignored when `csp.strict` is enabled. +*Default: `true`* + +| `elasticsearch.customHeaders:` + | Header names and values to send to {es}. Any custom headers cannot be +overwritten by client-side headers, regardless of the +`elasticsearch.requestHeadersWhitelist` configuration. *Default: `{}`* + +| `elasticsearch.hosts:` + | The URLs of the {es} instances to use for all your queries. All nodes +listed here must be on the same cluster. *Default: `[ "http://localhost:9200" ]`* + -To enable SSL/TLS for outbound connections to {es}, use the `https` protocol in this setting. - -`elasticsearch.logQueries:`:: *Default: `false`* Logs queries sent to -Elasticsearch. Requires `logging.verbose` set to `true`. This is useful for -seeing the query DSL generated by applications that currently do not have an -inspector, for example Timelion and Monitoring. - -`elasticsearch.pingTimeout:`:: -*Default: the value of the `elasticsearch.requestTimeout` setting* Time in -milliseconds to wait for Elasticsearch to respond to pings. - -`elasticsearch.preserveHost:`:: *Default: true* When this setting’s value is -true, Kibana uses the hostname specified in the `server.host` setting. When the -value of this setting is `false`, Kibana uses the hostname of the host that -connects to this Kibana instance. - -`elasticsearch.requestHeadersWhitelist:`:: *Default: `[ 'authorization' ]`* List -of Kibana client-side headers to send to Elasticsearch. To send *no* client-side -headers, set this value to [] (an empty list). -Removing the `authorization` header from being whitelisted means that you cannot -use <> in Kibana. - -`elasticsearch.requestTimeout:`:: *Default: 30000* Time in milliseconds to wait -for responses from the back end or Elasticsearch. This value must be a positive -integer. - -`elasticsearch.shardTimeout:`:: *Default: 30000* Time in milliseconds for -Elasticsearch to wait for responses from shards. Set to 0 to disable. - -`elasticsearch.sniffInterval:`:: *Default: false* Time in milliseconds between -requests to check Elasticsearch for an updated list of nodes. - -`elasticsearch.sniffOnStart:`:: *Default: false* Attempt to find other -Elasticsearch nodes on startup. - -`elasticsearch.sniffOnConnectionFault:`:: *Default: false* Update the list of -Elasticsearch nodes immediately following a connection fault. - -`elasticsearch.ssl.alwaysPresentCertificate:`:: *Default: false* Controls {kib}'s behavior in regard to presenting a client certificate when -requested by {es}. This setting applies to all outbound SSL/TLS connections to {es}, including requests that are proxied for end users. -+ -WARNING: If {es} uses certificates to authenticate end users with a PKI realm and `elasticsearch.ssl.alwaysPresentCertificate` is `true`, -proxied requests may be executed as the identity that is tied to the {kib} server. - -`elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:`:: Paths to a PEM-encoded X.509 client certificate and its corresponding -private key. These are used by {kib} to authenticate itself when making outbound SSL/TLS connections to {es}. For this setting to take -effect, the `xpack.security.http.ssl.client_authentication` setting in {es} must be also be set to `"required"` or `"optional"` to request a -client certificate from {kib}. +To enable SSL/TLS for outbound connections to {es}, use the `https` protocol +in this setting. + +| `elasticsearch.logQueries:` + | Log queries sent to {es}. Requires `logging.verbose` set to `true`. +This is useful for seeing the query DSL generated by applications that +currently do not have an inspector, for example Timelion and Monitoring. +*Default: `false`* + +| `elasticsearch.pingTimeout:` + | Time in milliseconds to wait for {es} to respond to pings. +*Default: the value of the `elasticsearch.requestTimeout` setting* + +| `elasticsearch.preserveHost:` + | When the value is `true`, {kib} uses the hostname specified in the +`server.host` setting. When the value is `false`, {kib} uses +the hostname of the host that connects to this {kib} instance. *Default: `true`* + +| `elasticsearch.requestHeadersWhitelist:` + | List of {kib} client-side headers to send to {es}. To send *no* client-side +headers, set this value to [] (an empty list). Removing the `authorization` +header from being whitelisted means that you cannot use +<> in {kib}. +*Default: `[ 'authorization' ]`* + +| `elasticsearch.requestTimeout:` + | Time in milliseconds to wait for responses from the back end or {es}. +This value must be a positive integer. *Default: `30000`* + +| `elasticsearch.shardTimeout:` + | Time in milliseconds for {es} to wait for responses from shards. +Set to 0 to disable. *Default: `30000`* + +| `elasticsearch.sniffInterval:` + | Time in milliseconds between requests to check {es} for an updated list of +nodes. *Default: `false`* + +| `elasticsearch.sniffOnStart:` + | Attempt to find other {es} nodes on startup. *Default: `false`* + +| `elasticsearch.sniffOnConnectionFault:` + | Update the list of {es} nodes immediately following a connection fault. +*Default: `false`* + +| `elasticsearch.ssl.alwaysPresentCertificate:` + | Controls {kib} behavior in regard to presenting a client certificate when +requested by {es}. This setting applies to all outbound SSL/TLS connections +to {es}, including requests that are proxied for end users. *Default: `false`* + +|=== + +[WARNING] +============ +When {es} uses certificates to authenticate end users with a PKI realm +and `elasticsearch.ssl.alwaysPresentCertificate` is `true`, +proxied requests may be executed as the identity that is tied to the {kib} +server. +============ + +[cols="2*<"] +|=== + +| `elasticsearch.ssl.certificate:` and `elasticsearch.ssl.key:` + | Paths to a PEM-encoded X.509 client certificate and its corresponding +private key. These are used by {kib} to authenticate itself when making +outbound SSL/TLS connections to {es}. For this setting to take effect, the +`xpack.security.http.ssl.client_authentication` setting in {es} must be also +be set to `"required"` or `"optional"` to request a client certificate from +{kib}. + +|=== + +[NOTE] +============ +These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. +============ + +[cols="2*<"] +|=== + +| `elasticsearch.ssl.certificateAuthorities:` + | Paths to one or more PEM-encoded X.509 certificate authority (CA) +certificates, which make up a trusted certificate chain for {es}. This chain is +used by {kib} to establish trust when making outbound SSL/TLS connections to +{es}. + -NOTE: These settings cannot be used in conjunction with `elasticsearch.ssl.keystore.path`. - -`elasticsearch.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a -trusted certificate chain for {es}. This chain is used by {kib} to establish trust when making outbound SSL/TLS connections to {es}. +In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.keystore.path` and/or `elasticsearch.ssl.truststore.path`. + +| `elasticsearch.ssl.keyPassphrase:` + | The password that decrypts the private key that is specified +via `elasticsearch.ssl.key`. This value is optional, as the key may not be +encrypted. + +| `elasticsearch.ssl.keystore.path:` + | Path to a PKCS#12 keystore that contains an X.509 client certificate and it's +corresponding private key. These are used by {kib} to authenticate itself when +making outbound SSL/TLS connections to {es}. For this setting, you must also set +the `xpack.security.http.ssl.client_authentication` setting in {es} to +`"required"` or `"optional"` to request a client certificate from {kib}. + -In addition to this setting, trusted certificates may be specified via `elasticsearch.ssl.keystore.path` and/or +If the keystore contains any additional certificates, they are used as a +trusted certificate chain for {es}. This chain is used by {kib} to establish +trust when making outbound SSL/TLS connections to {es}. In addition to this +setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. -`elasticsearch.ssl.keyPassphrase:`:: The password that will be used to decrypt the private key that is specified via -`elasticsearch.ssl.key`. This value is optional, as the key may not be encrypted. +|=== -`elasticsearch.ssl.keystore.path:`:: Path to a PKCS#12 keystore that contains an X.509 client certificate and its corresponding private key. -These are used by {kib} to authenticate itself when making outbound SSL/TLS connections to {es}. For this setting to take effect, the -`xpack.security.http.ssl.client_authentication` setting in {es} must also be set to `"required"` or `"optional"` to request a client -certificate from {kib}. -+ --- -If the keystore contains any additional certificates, those will be used as a trusted certificate chain for {es}. This chain is used by -{kib} to establish trust when making outbound SSL/TLS connections to {es}. In addition to this setting, trusted certificates may be -specified via `elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.truststore.path`. +[NOTE] +============ +This setting cannot be used in conjunction with +`elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. +============ -NOTE: This setting cannot be used in conjunction with `elasticsearch.ssl.certificate` or `elasticsearch.ssl.key`. --- +[cols="2*<"] +|=== -`elasticsearch.ssl.keystore.password:`:: The password that will be used to decrypt the keystore that is specified via -`elasticsearch.ssl.keystore.path`. If the keystore has no password, leave this unset. If the keystore has an empty password, set this to +| `elasticsearch.ssl.keystore.password:` + | The password that decrypts the keystore specified via +`elasticsearch.ssl.keystore.path`. If the keystore has no password, leave this +as blank. If the keystore has an empty password, set this to `""`. -`elasticsearch.ssl.truststore.path:`:: Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates -which make up a trusted certificate chain for {es}. This chain is used by {kib} to establish trust when making outbound SSL/TLS connections -to {es}. +| `elasticsearch.ssl.truststore.path:`:: + | Path to a PKCS#12 trust store that contains one or more X.509 certificate +authority (CA) certificates, which make up a trusted certificate chain for +{es}. This chain is used by {kib} to establish trust when making outbound +SSL/TLS connections to {es}. + -In addition to this setting, trusted certificates may be specified via `elasticsearch.ssl.certificateAuthorities` and/or +In addition to this setting, trusted certificates may be specified via +`elasticsearch.ssl.certificateAuthorities` and/or `elasticsearch.ssl.keystore.path`. -`elasticsearch.ssl.truststore.password:`:: The password that will be used to decrypt the trust store specified via -`elasticsearch.ssl.truststore.path`. If the trust store has no password, leave this unset. If the trust store has an empty password, set -this to `""`. - -`elasticsearch.ssl.verificationMode:`:: *Default: `"full"`* Controls the verification of the server certificate that {kib} receives when -making an outbound SSL/TLS connection to {es}. Valid values are `"full"`, `"certificate"`, and `"none"`. Using `"full"` will perform -hostname verification, using `"certificate"` will skip hostname verification, and using `"none"` will skip verification entirely. - -`elasticsearch.startupTimeout:`:: *Default: 5000* Time in milliseconds to wait -for Elasticsearch at Kibana startup before retrying. - -`elasticsearch.username:` and `elasticsearch.password:`:: If your Elasticsearch -is protected with basic authentication, these settings provide the username and -password that the Kibana server uses to perform maintenance on the Kibana index -at startup. Your Kibana users still need to authenticate with Elasticsearch, -which is proxied through the Kibana server. - -`interpreter.enableInVisualize`:: *Default: true* Enables use of interpreter in -Visualize. - -`kibana.defaultAppId:`:: *Default: "home"* The default application to load. - -`kibana.index:`:: *Default: ".kibana"* Kibana uses an index in Elasticsearch to -store saved searches, visualizations and dashboards. Kibana creates a new index -if the index doesn’t already exist. If you configure a custom index, the name must -be lowercase, and conform to {es} {ref}/indices-create-index.html[index name limitations]. - -`kibana.autocompleteTimeout:`:: *Default: "1000"* Time in milliseconds to wait -for autocomplete suggestions from Elasticsearch. This value must be a whole number -greater than zero. - -`kibana.autocompleteTerminateAfter:`:: *Default: "100000"* Maximum number of -documents loaded by each shard to generate autocomplete suggestions. This value -must be a whole number greater than zero. - -`logging.dest:`:: *Default: `stdout`* Enables you specify a file where Kibana -stores log output. - -`logging.json:`:: *Default: false* Logs output as JSON. When set to `true`, the -logs will be formatted as JSON strings that include timestamp, log level, context, message -text and any other metadata that may be associated with the log message itself. -If `logging.dest.stdout` is set and there is no interactive terminal ("TTY"), this setting -will default to `true`. - -`logging.quiet:`:: *Default: false* Set the value of this setting to `true` to -suppress all logging output other than error messages. - -`logging.rotate:`:: [experimental] Specifies the options for the logging rotate feature. +|`elasticsearch.ssl.truststore.password:` + | The password that decrypts the trust store specified via +`elasticsearch.ssl.truststore.path`. If the trust store has no password, +leave this as blank. If the trust store has an empty password, set this to `""`. + +| `elasticsearch.ssl.verificationMode:` + | Controls the verification of the server certificate that {kib} receives when +making an outbound SSL/TLS connection to {es}. Valid values are `"full"`, +`"certificate"`, and `"none"`. Using `"full"` performs hostname verification, +using `"certificate"` skips hostname verification, and using `"none"` skips +verification entirely. *Default: `"full"`* + +| `elasticsearch.startupTimeout:` + | Time in milliseconds to wait for {es} at {kib} startup before retrying. +*Default: `5000`* + +| `elasticsearch.username:` and `elasticsearch.password:` + | If your {es} is protected with basic authentication, these settings provide +the username and password that the {kib} server uses to perform maintenance +on the {kib} index at startup. {kib} users still need to authenticate with +{es}, which is proxied through the {kib} server. + +| `interpreter.enableInVisualize` + | Enables use of interpreter in Visualize. *Default: `true`* + +| `kibana.defaultAppId:` + | The default application to load. *Default: `"home"`* + +| `kibana.index:` + | {kib} uses an index in {es} to store saved searches, visualizations, and +dashboards. {kib} creates a new index if the index doesn’t already exist. +If you configure a custom index, the name must be lowercase, and conform to the +{es} {ref}/indices-create-index.html[index name limitations]. +*Default: `".kibana"`* + +| `kibana.autocompleteTimeout:` + | Time in milliseconds to wait for autocomplete suggestions from {es}. +This value must be a whole number greater than zero. *Default: `"1000"`* + +| `kibana.autocompleteTerminateAfter:` + | Maximum number of documents loaded by each shard to generate autocomplete +suggestions. This value must be a whole number greater than zero. +*Default: `"100000"`* + +| `logging.dest:` + | Enables you to specify a file where {kib} stores log output. +*Default: `stdout`* + +| `logging.json:` + | Logs output as JSON. When set to `true`, the logs are formatted as JSON +strings that include timestamp, log level, context, message text, and any other +metadata that may be associated with the log message. +When `logging.dest.stdout` is set, and there is no interactive terminal ("TTY"), +this setting defaults to `true`. *Default: `false`* + +| `logging.quiet:` + | Set the value of this setting to `true` to suppress all logging output other +than error messages. *Default: `false`* + +| `logging.rotate:` + | experimental[] Specifies the options for the logging rotate feature. When not defined, all the sub options defaults would be applied. The following example shows a valid logging rotate configuration: -+ + +|=== + +[source,text] -- - logging.rotate: - enabled: true - everyBytes: 10485760 - keepFiles: 10 + logging.rotate: + enabled: true + everyBytes: 10485760 + keepFiles: 10 -- -`logging.rotate.enabled:`:: [experimental] *Default: false* Set the value of this setting to `true` to +[cols="2*<"] +|=== + +| `logging.rotate.enabled:` + | experimental[] Set the value of this setting to `true` to enable log rotation. If you do not have a `logging.dest` set that is different from `stdout` -that feature would not take any effect. +that feature would not take any effect. *Default: `false`* -`logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the +| `logging.rotate.everyBytes:` + | experimental[] The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). *Default: `10485760`* -`logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep +| `logging.rotate.keepFiles:` + | experimental[] The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` -option has to be in the range of 2 to 1024 files. +option has to be in the range of 2 to 1024 files. *Default: `7`* -`logging.rotate.pollingInterval:`:: [experimental] *Default: 10000* The number of milliseconds for the polling strategy in case -the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. +| `logging.rotate.pollingInterval:` + | experimental[] The number of milliseconds for the polling strategy in case +the `logging.rotate.usePolling` is enabled. `logging.rotate.usePolling` must be in the 5000 to 3600000 millisecond range. *Default: `10000`* -`logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring +| `logging.rotate.usePolling:` + | experimental[] By default we try to understand the best way to monitoring the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, -the `polling` method could be used enabling that option. +the `polling` method could be used enabling that option. *Default: `false`* -`logging.silent:`:: *Default: false* Set the value of this setting to `true` to -suppress all logging output. +| `logging.silent:` + | Set the value of this setting to `true` to +suppress all logging output. *Default: `false`* -`logging.timezone`:: *Default: UTC* Set to the canonical timezone id -(for example, `America/Los_Angeles`) to log events using that timezone. A list of timezones can -be referenced at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. +| `logging.timezone` + | Set to the canonical timezone ID +(for example, `America/Los_Angeles`) to log events using that timezone. For a +list of timezones, refer to https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. *Default: `UTC`* -[[logging-verbose]]`logging.verbose:`:: *Default: false* Set the value of this -setting to `true` to log all events, including system usage information and all -requests. Supported on Elastic Cloud Enterprise. +| [[logging-verbose]] `logging.verbose:` + | Set to `true` to log all events, including system usage information and all +requests. Supported on {ece}. *Default: `false`* -`map.includeElasticMapsService:`:: *Default: true* -Set to false to disable connections to Elastic Maps Service. +| `map.includeElasticMapsService:` + | Set to `false` to disable connections to Elastic Maps Service. When `includeElasticMapsService` is turned off, only the vector layers configured by `map.regionmap` -and the tile layer configured by `map.tilemap.url` will be available in <>. +and the tile layer configured by `map.tilemap.url` are available in <>. *Default: `true`* -`map.proxyElasticMapsServiceInMaps:`:: *Default: false* -Set to true to proxy all <> Elastic Maps Service requests through the Kibana server. +| `map.proxyElasticMapsServiceInMaps:` + | Set to `true` to proxy all <> Elastic Maps Service +requests through the {kib} server. *Default: `false`* -[[regionmap-settings]] `map.regionmap:`:: Specifies additional vector layers for +| [[regionmap-settings]] `map.regionmap:` + | Specifies additional vector layers for use in <> visualizations. Supported on {ece}. Each layer object points to an external vector file that contains a geojson FeatureCollection. The file must use the https://en.wikipedia.org/wiki/World_Geodetic_System[WGS84 coordinate reference system (ESPG:4326)] and only include polygons. If the file is hosted on a separate domain from -Kibana, the server needs to be CORS-enabled so Kibana can download the file. -[[region-map-configuration-example]] +{kib}, the server needs to be CORS-enabled so {kib} can download the file. The following example shows a valid region map configuration. -+ + +|=== + +[source,text] -- - map - includeElasticMapsService: false - regionmap: - layers: - - name: "Departments of France" - url: "http://my.cors.enabled.server.org/france_departements.geojson" - attribution: "INRAP" - fields: - - name: "department" - description: "Full department name" - - name: "INSEE" - description: "INSEE numeric identifier" +map.regionmap: + includeElasticMapsService: false + layers: + - name: "Departments of France" + url: "http://my.cors.enabled.server.org/france_departements.geojson" + attribution: "INRAP" + fields: + - name: "department" + description: "Full department name" + - name: "INSEE" + description: "INSEE numeric identifier" -- -[[regionmap-ES-map]]`map.includeElasticMapsService:`:: Turns on or off -whether layers from the Elastic Maps Service should be included in the vector -layer option list. Supported on Elastic Cloud Enterprise. By turning this off, +[cols="2*<"] +|=== + +| [[regionmap-ES-map]] `map.includeElasticMapsService:` + | Turns on or off whether layers from the Elastic Maps Service should be included in the vector +layer option list. Supported on {ece}. By turning this off, only the layers that are configured here will be included. The default is `true`. This also affects whether tile-service from the Elastic Maps Service will be available. -[[regionmap-attribution]]`map.regionmap.layers[].attribution:`:: Optional. -References the originating source of the geojson file. Supported on {ece}. +| [[regionmap-attribution]] `map.regionmap.layers[].attribution:` + | Optional. References the originating source of the geojson file. +Supported on {ece}. -[[regionmap-fields]]`map.regionmap.layers[].fields[]:`:: Mandatory. Each layer +| [[regionmap-fields]] `map.regionmap.layers[].fields[]:` + | Mandatory. Each layer can contain multiple fields to indicate what properties from the geojson -features you wish to expose. This <> shows how to define multiple -properties. Supported on {ece}. +features you wish to expose. Supported on {ece}. The following shows how to define multiple +properties: + +|=== -[[regionmap-field-description]]`map.regionmap.layers[].fields[].description:`:: -Mandatory. The human readable text that is shown under the Options tab when +[source,text] +-- +map.regionmap: + includeElasticMapsService: false + layers: + - name: "Departments of France" + url: "http://my.cors.enabled.server.org/france_departements.geojson" + attribution: "INRAP" + fields: + - name: "department" + description: "Full department name" + - name: "INSEE" + description: "INSEE numeric identifier" +-- + +[cols="2*<"] +|=== + +| [[regionmap-field-description]] `map.regionmap.layers[].fields[].description:` + | Mandatory. The human readable text that is shown under the Options tab when building the Region Map visualization. Supported on {ece}. -[[regionmap-field-name]]`map.regionmap.layers[].fields[].name:`:: Mandatory. +| [[regionmap-field-name]] `map.regionmap.layers[].fields[].name:` + | Mandatory. This value is used to do an inner-join between the document stored in -Elasticsearch and the geojson file. For example, if the field in the geojson is -called `Location` and has city names, there must be a field in Elasticsearch -that holds the same values that Kibana can then use to lookup for the geoshape +{es} and the geojson file. For example, if the field in the geojson is +called `Location` and has city names, there must be a field in {es} +that holds the same values that {kib} can then use to lookup for the geoshape data. Supported on {ece}. -[[regionmap-name]]`map.regionmap.layers[].name:`:: Mandatory. A description of +| [[regionmap-name]] `map.regionmap.layers[].name:` + | Mandatory. A description of the map being provided. Supported on {ece}. -[[regionmap-url]]`map.regionmap.layers[].url:`:: Mandatory. The location of the +| [[regionmap-url]] `map.regionmap.layers[].url:` + | Mandatory. The location of the geojson file as provided by a webserver. Supported on {ece}. -[[tilemap-settings]] `map.tilemap.options.attribution:`:: +| [[tilemap-settings]] `map.tilemap.options.attribution:` + | The map attribution string. Supported on {ece}. *Default: `"© [Elastic Maps Service](https://www.elastic.co/elastic-maps-service)"`* -The map attribution string. Supported on {ece}. -[[tilemap-max-zoom]]`map.tilemap.options.maxZoom:`:: *Default: 10* The maximum -zoom level. Supported on {ece}. +| [[tilemap-max-zoom]] `map.tilemap.options.maxZoom:` + | The maximum zoom level. Supported on {ece}. *Default: `10`* -[[tilemap-min-zoom]]`map.tilemap.options.minZoom:`:: *Default: 1* The minimum -zoom level. Supported on {ece}. +| [[tilemap-min-zoom]] `map.tilemap.options.minZoom:` + | The minimum zoom level. Supported on {ece}. *Default: `1`* -[[tilemap-subdomains]]`map.tilemap.options.subdomains:`:: An array of subdomains +| [[tilemap-subdomains]] `map.tilemap.options.subdomains:` + | An array of subdomains used by the tile service. Specify the position of the subdomain the URL with the token `{s}`. Supported on {ece}. -[[tilemap-url]]`map.tilemap.url:`:: The URL to the tileservice that Kibana uses +| [[tilemap-url]] `map.tilemap.url:` + | The URL to the tileservice that {kib} uses to display map tiles in tilemap visualizations. Supported on {ece}. By default, -Kibana reads this url from an external metadata service, but users can still +{kib} reads this URL from an external metadata service, but users can override this parameter to use their own Tile Map Service. For example: `"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"` -`newsfeed.enabled:` :: *Default: `true`* Controls whether to enable the newsfeed -system for the Kibana UI notification center. Set to `false` to disable the -newsfeed system. +| `newsfeed.enabled:` + | Controls whether to enable the newsfeed +system for the {kib} UI notification center. Set to `false` to disable the +newsfeed system. *Default: `true`* -`path.data:`:: *Default: `data`* The path where Kibana stores persistent data -not saved in Elasticsearch. +| `path.data:` + | The path where {kib} stores persistent data +not saved in {es}. *Default: `data`* -`pid.file:`:: Specifies the path where Kibana creates the process ID file. +| `pid.file:` + | Specifies the path where {kib} creates the process ID file. -`ops.interval:`:: *Default: 5000* Set the interval in milliseconds to sample -system and process performance metrics. The minimum value is 100. +| `ops.interval:` + | Set the interval in milliseconds to sample +system and process performance metrics. The minimum value is 100. *Default: `5000`* -`server.basePath:`:: Enables you to specify a path to mount Kibana at if you are -running behind a proxy. Use the `server.rewriteBasePath` setting to tell Kibana +| `server.basePath:` + | Enables you to specify a path to mount {kib} at if you are +running behind a proxy. Use the `server.rewriteBasePath` setting to tell {kib} if it should remove the basePath from requests it receives, and to prevent a deprecation warning at startup. This setting cannot end in a slash (`/`). -[[server-compression]]`server.compression.enabled:`:: *Default: `true`* Set to `false` to disable HTTP compression for all responses. +| [[server-compression]] `server.compression.enabled:` + | Set to `false` to disable HTTP compression for all responses. *Default: `true`* -`server.compression.referrerWhitelist:`:: *Default: none* Specifies an array of trusted hostnames, such as the Kibana host, or a reverse -proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request's `Referer` header. -This setting may not be used when `server.compression.enabled` is set to `false`. +| `server.compression.referrerWhitelist:` + | Specifies an array of trusted hostnames, such as the {kib} host, or a reverse +proxy sitting in front of it. This determines whether HTTP compression may be used for responses, based on the request `Referer` header. +This setting may not be used when `server.compression.enabled` is set to `false`. *Default: `none`* -`server.customResponseHeaders:`:: *Default: `{}`* Header names and values to - send on all responses to the client from the Kibana server. +| `server.customResponseHeaders:` + | Header names and values to +send on all responses to the client from the {kib} server. *Default: `{}`* -`server.host:`:: *Default: "localhost"* This setting specifies the host of the -back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {kib} server. +| `server.host:` + | This setting specifies the host of the +back end server. To allow remote users to connect, set the value to the IP address or DNS name of the {kib} server. *Default: `"localhost"`* -`server.keepaliveTimeout:`:: *Default: "120000"* The number of milliseconds to wait for additional data before restarting -the `server.socketTimeout` counter. +| `server.keepaliveTimeout:` + | The number of milliseconds to wait for additional data before restarting +the `server.socketTimeout` counter. *Default: `"120000"`* -`server.maxPayloadBytes:`:: *Default: 1048576* The maximum payload size in bytes -for incoming server requests. +| `server.maxPayloadBytes:` + | The maximum payload size in bytes +for incoming server requests. *Default: `1048576`* -`server.name:`:: *Default: "your-hostname"* A human-readable display name that -identifies this Kibana instance. +| `server.name:` + | A human-readable display name that +identifies this {kib} instance. *Default: `"your-hostname"`* -`server.port:`:: *Default: 5601* Kibana is served by a back end server. This -setting specifies the port to use. +| `server.port:` + | {kib} is served by a back end server. This +setting specifies the port to use. *Default: `5601`* -`server.rewriteBasePath:`:: *Default: deprecated* Specifies whether Kibana should +| `server.rewriteBasePath:` + | Specifies whether {kib} should rewrite requests that are prefixed with `server.basePath` or require that they -are rewritten by your reverse proxy. In Kibana 6.3 and earlier, the default is -`false`. In Kibana 7.x, the setting is deprecated. In Kibana 8.0 and later, the -default is `true`. +are rewritten by your reverse proxy. In {kib} 6.3 and earlier, the default is +`false`. In {kib} 7.x, the setting is deprecated. In {kib} 8.0 and later, the +default is `true`. *Default: `deprecated`* -`server.socketTimeout:`:: *Default: "120000"* The number of milliseconds to wait before closing an -inactive socket. +| `server.socketTimeout:` + | The number of milliseconds to wait before closing an +inactive socket. *Default: `"120000"`* -`server.ssl.certificate:` and `server.ssl.key:`:: Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These -are used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. -+ -NOTE: These settings cannot be used in conjunction with `server.ssl.keystore.path`. +| `server.ssl.certificate:` and `server.ssl.key:` + | Paths to a PEM-encoded X.509 server certificate and its corresponding private key. These +are used by {kib} to establish trust when receiving inbound SSL/TLS connections from users. + +|=== -`server.ssl.certificateAuthorities:`:: Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a +[NOTE] +============ +These settings cannot be used in conjunction with `server.ssl.keystore.path`. +============ + +[cols="2*<"] +|=== + +| `server.ssl.certificateAuthorities:` + | Paths to one or more PEM-encoded X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {kib}. This chain is used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {kib} to verify client certificates from end users. + In addition to this setting, trusted certificates may be specified via `server.ssl.keystore.path` and/or `server.ssl.truststore.path`. -`server.ssl.cipherSuites:`:: *Default: ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA*. -Details on the format, and the valid options, are available via the +| `server.ssl.cipherSuites:` + | Details on the format, and the valid options, are available via the https://www.openssl.org/docs/man1.0.2/apps/ciphers.html#CIPHER-LIST-FORMAT[OpenSSL cipher list format documentation]. +*Default: `ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-AES256-GCM-SHA384, DHE-RSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-SHA256, DHE-RSA-AES128-SHA256, ECDHE-RSA-AES256-SHA384, DHE-RSA-AES256-SHA384, ECDHE-RSA-AES256-SHA256, DHE-RSA-AES256-SHA256, HIGH,!aNULL, !eNULL, !EXPORT, !DES, !RC4, !MD5, !PSK, !SRP, !CAMELLIA`*. -`server.ssl.clientAuthentication:`:: *Default: `"none"`* Controls {kib}’s behavior in regard to requesting a certificate from client +| `server.ssl.clientAuthentication:` + | Controls the behavior in {kib} for requesting a certificate from client connections. Valid values are `"required"`, `"optional"`, and `"none"`. Using `"required"` will refuse to establish the connection unless a client presents a certificate, using `"optional"` will allow a client to present a certificate if it has one, and using `"none"` will -prevent a client from presenting a certificate. +prevent a client from presenting a certificate. *Default: `"none"`* -`server.ssl.enabled:`:: *Default: `false`* Enables SSL/TLS for inbound connections to {kib}. When set to `true`, a certificate and its +| `server.ssl.enabled:` + | Enables SSL/TLS for inbound connections to {kib}. When set to `true`, a certificate and its corresponding private key must be provided. These can be specified via `server.ssl.keystore.path` or the combination of -`server.ssl.certificate` and `server.ssl.key`. +`server.ssl.certificate` and `server.ssl.key`. *Default: `false`* -`server.ssl.keyPassphrase:`:: The password that will be used to decrypt the private key that is specified via `server.ssl.key`. This value +| `server.ssl.keyPassphrase:` + | The password that decrypts the private key that is specified via `server.ssl.key`. This value is optional, as the key may not be encrypted. -`server.ssl.keystore.path:`:: Path to a PKCS#12 keystore that contains an X.509 server certificate and its corresponding private key. If the +| `server.ssl.keystore.path:` + | Path to a PKCS#12 keystore that contains an X.509 server certificate and its corresponding private key. If the keystore contains any additional certificates, those will be used as a trusted certificate chain for {kib}. All of these are used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. The certificate chain is also used by {kib} to verify client certificates from end users when PKI authentication is enabled. + --- In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or `server.ssl.truststore.path`. -NOTE: This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key`. --- +|=== + +[NOTE] +============ +This setting cannot be used in conjunction with `server.ssl.certificate` or `server.ssl.key` +============ -`server.ssl.keystore.password:`:: The password that will be used to decrypt the keystore specified via `server.ssl.keystore.path`. If the +[cols="2*<"] +|=== + +| `server.ssl.keystore.password:` + | The password that will be used to decrypt the keystore specified via `server.ssl.keystore.path`. If the keystore has no password, leave this unset. If the keystore has an empty password, set this to `""`. -`server.ssl.truststore.path:`:: Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates which +| `server.ssl.truststore.path:` + | Path to a PKCS#12 trust store that contains one or more X.509 certificate authority (CA) certificates which make up a trusted certificate chain for {kib}. This chain is used by {kib} to establish trust when receiving inbound SSL/TLS connections from end users. If PKI authentication is enabled, this chain is also used by {kib} to verify client certificates from end users. + In addition to this setting, trusted certificates may be specified via `server.ssl.certificateAuthorities` and/or `server.ssl.keystore.path`. -`server.ssl.truststore.password:`:: The password that will be used to decrypt the trust store specified via `server.ssl.truststore.path`. If +| `server.ssl.truststore.password:` + | The password that will be used to decrypt the trust store specified via `server.ssl.truststore.path`. If the trust store has no password, leave this unset. If the trust store has an empty password, set this to `""`. -`server.ssl.redirectHttpFromPort:`:: Kibana will bind to this port and redirect +| `server.ssl.redirectHttpFromPort:` + | {kib} binds to this port and redirects all http requests to https over the port configured as `server.port`. -`server.ssl.supportedProtocols:`:: *Default: TLSv1.1, TLSv1.2* An array of -supported protocols with versions. Valid protocols: `TLSv1`, `TLSv1.1`, `TLSv1.2` +| `server.ssl.supportedProtocols:` + | An array of supported protocols with versions. +Valid protocols: `TLSv1`, `TLSv1.1`, `TLSv1.2`. *Default: TLSv1.1, TLSv1.2* -`server.xsrf.whitelist:`:: It is not recommended to disable protections for +| `server.xsrf.whitelist:` + | It is not recommended to disable protections for arbitrary API endpoints. Instead, supply the `kbn-xsrf` header. The `server.xsrf.whitelist` setting requires the following format: -[source,text] +|=== +[source,text] ---- *Default: [ ]* An array of API endpoints which should be exempt from Cross-Site Request Forgery ("XSRF") protections. ---- -`status.allowAnonymous:`:: *Default: false* If authentication is enabled, -setting this to `true` enables unauthenticated users to access the Kibana -server status API and status page. +[cols="2*<"] +|=== + +| `status.allowAnonymous:` + | If authentication is enabled, +setting this to `true` enables unauthenticated users to access the {kib} +server status API and status page. *Default: `false`* -`telemetry.allowChangingOptInStatus`:: *Default: true*. If `true`, -users are able to change the telemetry setting at a later time in -<>. If `false`, +| `telemetry.allowChangingOptInStatus` + | When `true`, users are able to change the telemetry setting at a later time in +<>. When `false`, {kib} looks at the value of `telemetry.optIn` to determine whether to send telemetry data or not. `telemetry.allowChangingOptInStatus` and `telemetry.optIn` -cannot be `false` at the same time. +cannot be `false` at the same time. *Default: `true`*. -`telemetry.optIn`:: *Default: true* If `true`, telemetry data is sent to Elastic. - If `false`, collection of telemetry data is disabled. - To enable telemetry and prevent users from disabling it, - set `telemetry.allowChangingOptInStatus` to `false` and `telemetry.optIn` to `true`. +| `telemetry.optIn` + | When `true`, telemetry data is sent to Elastic. +When `false`, collection of telemetry data is disabled. +To enable telemetry and prevent users from disabling it, +set `telemetry.allowChangingOptInStatus` to `false` and `telemetry.optIn` to `true`. +*Default: `true`* -`telemetry.enabled`:: *Default: true* Reporting your cluster statistics helps +| `telemetry.enabled` + | Reporting your cluster statistics helps us improve your user experience. Your data is never shared with anyone. Set to `false` to disable telemetry capabilities entirely. You can alternatively opt -out through the *Advanced Settings* in {kib}. +out through *Advanced Settings*. *Default: `true`* + +| `vis_type_vega.enableExternalUrls:` + | Set this value to true to allow Vega to use any URL to access external data +sources and images. When false, Vega can only get data from {es}. *Default: `false`* -`vis_type_vega.enableExternalUrls:`:: *Default: false* Set this value to true to allow Vega to use any URL to access external data sources and images. If false, Vega can only get data from Elasticsearch. +| `xpack.license_management.enabled` + | Set this value to false to +disable the License Management UI. *Default: `true`* -`xpack.license_management.enabled`:: *Default: true* Set this value to false to -disable the License Management user interface. +| `xpack.rollup.enabled:` + | Set this value to false to disable the +Rollup UI. *Default: true* -`xpack.rollup.enabled:`:: *Default: true* Set this value to false to disable the -Rollup user interface. +| `i18n.locale` + | Set this value to change the {kib} interface language. +Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* -`i18n.locale`:: *Default: en* Set this value to change the Kibana interface language. Valid locales are: `en`, `zh-CN`, `ja-JP`. +|=== include::{docdir}/settings/alert-action-settings.asciidoc[] include::{docdir}/settings/apm-settings.asciidoc[] diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index abdcc7d1ba524da..673b4f6263e18f3 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -92,7 +92,7 @@ section of the alert configuration and selecting *Add new*. * Alternatively, create a connector by navigating to *Management* from the {kib} navbar and selecting *Alerts and Actions*. Then, select the *Connectors* tab, click the *Create connector* button, and select the PagerDuty option. -. Configure the connector by giving it a name and optionally entering the API URL and Routing Key, or using the defaults. +. Configure the connector by giving it a name and entering the Integration Key, optionally entering a custom API URL. + See <> for how to obtain the endpoint and key information from PagerDuty and <> for more details. @@ -133,7 +133,7 @@ PagerDuty connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. -Routing Key:: A 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. +Integration Key:: A 32 character PagerDuty Integration Key for an integration on a service, also referred to as the routing key. [float] [[pagerduty-action-configuration]] diff --git a/examples/ui_action_examples/public/index.ts b/examples/ui_action_examples/public/index.ts index 88a36d278e256e1..5b08192a1196ecd 100644 --- a/examples/ui_action_examples/public/index.ts +++ b/examples/ui_action_examples/public/index.ts @@ -18,9 +18,8 @@ */ import { UiActionExamplesPlugin } from './plugin'; -import { PluginInitializer } from '../../../src/core/public'; -export const plugin: PluginInitializer = () => new UiActionExamplesPlugin(); +export const plugin = () => new UiActionExamplesPlugin(); export { HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger'; export { ACTION_HELLO_WORLD } from './hello_world_action'; diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index c47746d4b3fd6e3..3a9f673261e330d 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -17,15 +17,19 @@ * under the License. */ -import { Plugin, CoreSetup } from '../../../src/core/public'; -import { UiActionsSetup } from '../../../src/plugins/ui_actions/public'; +import { Plugin, CoreSetup, CoreStart } from '../../../src/core/public'; +import { UiActionsSetup, UiActionsStart } from '../../../src/plugins/ui_actions/public'; import { createHelloWorldAction, ACTION_HELLO_WORLD } from './hello_world_action'; import { helloWorldTrigger, HELLO_WORLD_TRIGGER_ID } from './hello_world_trigger'; -interface UiActionExamplesSetupDependencies { +export interface UiActionExamplesSetupDependencies { uiActions: UiActionsSetup; } +export interface UiActionExamplesStartDependencies { + uiActions: UiActionsStart; +} + declare module '../../../src/plugins/ui_actions/public' { export interface TriggerContextMapping { [HELLO_WORLD_TRIGGER_ID]: {}; @@ -37,8 +41,12 @@ declare module '../../../src/plugins/ui_actions/public' { } export class UiActionExamplesPlugin - implements Plugin { - public setup(core: CoreSetup, { uiActions }: UiActionExamplesSetupDependencies) { + implements + Plugin { + public setup( + core: CoreSetup, + { uiActions }: UiActionExamplesSetupDependencies + ) { uiActions.registerTrigger(helloWorldTrigger); const helloWorldAction = createHelloWorldAction(async () => ({ @@ -46,9 +54,10 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); + uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); } - public start() {} + public start(core: CoreStart, plugins: UiActionExamplesStartDependencies) {} + public stop() {} } diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 462f5c3bf88ba9c..f08b8bb29bdd351 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,8 +95,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.registerAction(dynamicAction); - uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index f1895905a45e16a..de86b51aee3a8c2 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.attachAction( + deps.uiActions.addTriggerAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/package.json b/package.json index 0ad304fdf2f690b..e711235e16ea537 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", + "accept": "3.0.2", "angular": "^1.7.9", "angular-aria": "^1.7.9", "angular-elastic": "^2.5.1", @@ -310,6 +311,7 @@ "@percy/agent": "^0.26.0", "@testing-library/react": "^9.3.2", "@testing-library/react-hooks": "^3.2.1", + "@types/accept": "3.1.1", "@types/angular": "^1.6.56", "@types/angular-mocks": "^1.7.0", "@types/babel__core": "^7.1.2", @@ -400,7 +402,7 @@ "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", "babel-plugin-istanbul": "^6.0.0", - "backport": "5.1.3", + "backport": "5.4.1", "chai": "3.5.0", "chance": "1.0.18", "cheerio": "0.22.0", diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index b3e5a8c518682e3..b7c9a63897bf9ff 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -14,6 +14,7 @@ "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/compression-webpack-plugin": "^2.0.1", "@types/estree": "^0.0.44", "@types/loader-utils": "^1.1.3", "@types/watchpack": "^1.1.5", @@ -23,6 +24,7 @@ "autoprefixer": "^9.7.4", "babel-loader": "^8.0.6", "clean-webpack-plugin": "^3.0.0", + "compression-webpack-plugin": "^3.1.0", "cpy": "^8.0.0", "css-loader": "^3.4.2", "del": "^5.1.0", 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 ad743933e117110..248b0b7cf4c9717 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -19,6 +19,7 @@ import Path from 'path'; import Fs from 'fs'; +import Zlib from 'zlib'; import { inspect } from 'util'; import cpy from 'cpy'; @@ -124,17 +125,12 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { ); assert('produce zero unexpected states', otherStates.length === 0, otherStates); - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/foo.plugin.js'), 'utf8') - ).toMatchSnapshot('foo bundle'); - - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/1.plugin.js'), 'utf8') - ).toMatchSnapshot('1 async bundle'); - - expect( - Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/bar/target/public/bar.plugin.js'), 'utf8') - ).toMatchSnapshot('bar bundle'); + expectFileMatchesSnapshotWithCompression('plugins/foo/target/public/foo.plugin.js', 'foo bundle'); + expectFileMatchesSnapshotWithCompression( + 'plugins/foo/target/public/1.plugin.js', + '1 async bundle' + ); + expectFileMatchesSnapshotWithCompression('plugins/bar/target/public/bar.plugin.js', 'bar bundle'); const foo = config.bundles.find(b => b.id === 'foo')!; expect(foo).toBeTruthy(); @@ -203,3 +199,24 @@ it('uses cache on second run and exist cleanly', async () => { ] `); }); + +/** + * Verifies that the file matches the expected output and has matching compressed variants. + */ +const expectFileMatchesSnapshotWithCompression = (filePath: string, snapshotLabel: string) => { + const raw = Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, filePath), 'utf8'); + expect(raw).toMatchSnapshot(snapshotLabel); + + // Verify the brotli variant matches + expect( + // @ts-ignore @types/node is missing the brotli functions + Zlib.brotliDecompressSync( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.br`)) + ).toString() + ).toEqual(raw); + + // Verify the gzip variant matches + expect( + Zlib.gunzipSync(Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.gz`))).toString() + ).toEqual(raw); +}; diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index cc3fa8c2720ded7..95e826e7620aa6e 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -28,6 +28,7 @@ import TerserPlugin from 'terser-webpack-plugin'; import webpackMerge from 'webpack-merge'; // @ts-ignore import { CleanWebpackPlugin } from 'clean-webpack-plugin'; +import CompressionPlugin from 'compression-webpack-plugin'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { Bundle, WorkerConfig, parseDirPath, DisallowedSyntaxPlugin } from '../common'; @@ -319,6 +320,16 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { IS_KIBANA_DISTRIBUTABLE: `"true"`, }, }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], optimization: { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index a60e2b0449d9589..ec61e8519c960e7 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -14,6 +14,7 @@ "@kbn/i18n": "1.0.0", "abortcontroller-polyfill": "^1.4.0", "angular": "^1.7.9", + "compression-webpack-plugin": "^3.1.0", "core-js": "^3.6.4", "custom-event-polyfill": "^0.3.0", "elasticsearch-browser": "^16.7.0", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index bf63c577658595e..52e7bb620b50b47 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -20,6 +20,7 @@ const Path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/dev-utils'); const webpack = require('webpack'); @@ -117,5 +118,15 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ new webpack.DefinePlugin({ 'process.env.NODE_ENV': dev ? '"development"' : '"production"', }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], }); diff --git a/renovate.json5 b/renovate.json5 index c0ddcaf4f23c8f1..61b2485ecf44b00 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -398,6 +398,8 @@ '@types/good-squeeze', 'inert', '@types/inert', + 'accept', + '@types/accept', ], }, { diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index 376b320b64ea9af..c085fb028cd5a7a 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -80,5 +80,6 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppLeaveHandler={[Function]} + setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index e29837aecb1253d..04ff844ffc15054 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -20,7 +20,7 @@ import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; @@ -30,6 +30,7 @@ import { MockCapabilitiesService, MockHistory } from './application_service.test import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; +import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { return { @@ -452,9 +453,9 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - const mount = () => () => undefined; - registerMountContext(pluginId, 'test' as any, mount); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + const appMount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, appMount); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', appMount); }); }); @@ -809,6 +810,74 @@ describe('#start()', () => { `); }); + it('updates httpLoadingCount$ while mounting', async () => { + // Use a memory history so that mounting the component will work + const { createMemoryHistory } = jest.requireActual('history'); + const history = createMemoryHistory(); + setupDeps.history = history; + + const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + // Create an app and a promise that allows us to control when the app completes mounting + const createWaitingApp = (props: Partial): [App, () => void] => { + let finishMount: () => void; + const mountPromise = new Promise(resolve => (finishMount = resolve)); + const app = { + id: 'some-id', + title: 'some-title', + mount: async () => { + await mountPromise; + return () => undefined; + }, + ...props, + }; + + return [app, finishMount!]; + }; + + // Create some dummy applications + const { register } = service.setup(setupDeps); + const [alphaApp, finishAlphaMount] = createWaitingApp({ id: 'alpha' }); + const [betaApp, finishBetaMount] = createWaitingApp({ id: 'beta' }); + register(Symbol(), alphaApp); + register(Symbol(), betaApp); + + const { navigateToApp, getComponent } = await service.start(startDeps); + const httpLoadingCount$ = startDeps.http.addLoadingCountSource.mock.calls[0][0]; + const stop$ = new Subject(); + const currentLoadingCount$ = new BehaviorSubject(0); + httpLoadingCount$.pipe(takeUntil(stop$)).subscribe(currentLoadingCount$); + const loadingPromise = httpLoadingCount$.pipe(bufferCount(5), takeUntil(stop$)).toPromise(); + mount(getComponent()!); + + await act(() => navigateToApp('alpha')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishAlphaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + await act(() => navigateToApp('beta')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishBetaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + stop$.next(); + const loadingCounts = await loadingPromise; + expect(loadingCounts).toMatchInlineSnapshot(` + Array [ + 0, + 1, + 0, + 1, + 0, + ] + `); + }); + it('sets window.location.href when navigating to legacy apps', async () => { setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index bafa1932e5e9273..0dd77072e9eafbd 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -238,6 +238,9 @@ export class ApplicationService { throw new Error('ApplicationService#setup() must be invoked before start.'); } + const httpLoadingCount$ = new BehaviorSubject(0); + http.addLoadingCountSource(httpLoadingCount$); + this.registrationClosed = true; window.addEventListener('beforeunload', this.onBeforeUnload); @@ -303,6 +306,7 @@ export class ApplicationService { mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} + setIsMounting={isMounting => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); }, diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 2f26bc1409104d2..915c58b28ad6d17 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -40,7 +40,7 @@ describe('AppContainer', () => { }; const mockMountersToMounters = () => new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); - const setAppLeaveHandlerMock = () => undefined; + const noop = () => undefined; const mountersToAppStatus$ = () => { return new BehaviorSubject( @@ -86,7 +86,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={appStatuses$} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); }); @@ -98,7 +99,7 @@ describe('AppContainer', () => { expect(app1.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -110,7 +111,7 @@ describe('AppContainer', () => { expect(app1Unmount).toHaveBeenCalled(); expect(app2.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app2 html:
App 2
" @@ -124,7 +125,7 @@ describe('AppContainer', () => { expect(standardApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -136,7 +137,7 @@ describe('AppContainer', () => { expect(standardAppUnmount).toHaveBeenCalled(); expect(chromelessApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -148,7 +149,7 @@ describe('AppContainer', () => { expect(chromelessAppUnmount).toHaveBeenCalled(); expect(standardApp.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -162,7 +163,7 @@ describe('AppContainer', () => { expect(chromelessAppA.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -174,7 +175,7 @@ describe('AppContainer', () => { expect(chromelessAppAUnmount).toHaveBeenCalled(); expect(chromelessAppB.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-b/path html:
Chromeless B
" @@ -186,7 +187,7 @@ describe('AppContainer', () => { expect(chromelessAppBUnmount).toHaveBeenCalled(); expect(chromelessAppA.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -214,7 +215,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -245,7 +247,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -286,7 +289,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index 9092177da5ad4b8..fa04b56f83ba1da 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -18,6 +18,7 @@ */ import React, { ReactElement } from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; @@ -34,7 +35,9 @@ export const createRenderer = (element: ReactElement | null): Renderer => { return () => new Promise(async resolve => { if (dom) { - dom.update(); + await act(async () => { + dom.update(); + }); } setImmediate(() => resolve(dom)); // flushes any pending promises }); diff --git a/src/core/public/application/ui/app_container.scss b/src/core/public/application/ui/app_container.scss new file mode 100644 index 000000000000000..4f8fec10a97e15f --- /dev/null +++ b/src/core/public/application/ui/app_container.scss @@ -0,0 +1,25 @@ +.appContainer__loading { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: $euiZLevel1; + animation-name: appContainerFadeIn; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 2s; +} + +@keyframes appContainerFadeIn { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index c538227e8f09884..2ee71a5bde7dc13 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { AppContainer } from './app_container'; @@ -28,6 +29,12 @@ import { ScopedHistory } from '../scoped_history'; describe('AppContainer', () => { const appId = 'someApp'; const setAppLeaveHandler = jest.fn(); + const setIsMounting = jest.fn(); + + beforeEach(() => { + setAppLeaveHandler.mockClear(); + setIsMounting.mockClear(); + }); const flushPromises = async () => { await new Promise(async resolve => { @@ -67,6 +74,7 @@ describe('AppContainer', () => { appStatus={AppStatus.inaccessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) @@ -86,10 +94,86 @@ describe('AppContainer', () => { expect(wrapper.text()).toEqual(''); - resolvePromise(); - await flushPromises(); - wrapper.update(); + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); expect(wrapper.text()).toContain('some-content'); }); + + it('should call setIsMounting while mounting', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = createMounter(waitPromise); + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); + + it('should call setIsMounting(false) if mounting throws', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = { + appBasePath: '/base-path', + appRoute: '/some-route', + unmountBeforeMounting: false, + mount: async ({ element }: AppMountParameters) => { + await waitPromise; + throw new Error(`Mounting failed!`); + }, + }; + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + // await expect( + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + // ).rejects.toThrow(); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); }); diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index e12a0f2cf2fcd05..aad7e6dcf270a71 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -26,9 +26,11 @@ import React, { MutableRefObject, } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; +import './app_container.scss'; interface Props { /** Path application is mounted on without the global basePath */ @@ -38,6 +40,7 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; createScopedHistory: (appUrl: string) => ScopedHistory; + setIsMounting: (isMounting: boolean) => void; } export const AppContainer: FunctionComponent = ({ @@ -47,7 +50,9 @@ export const AppContainer: FunctionComponent = ({ setAppLeaveHandler, createScopedHistory, appStatus, + setIsMounting, }: Props) => { + const [showSpinner, setShowSpinner] = useState(true); const [appNotFound, setAppNotFound] = useState(false); const elementRef = useRef(null); const unmountRef: MutableRefObject = useRef(null); @@ -65,28 +70,42 @@ export const AppContainer: FunctionComponent = ({ } setAppNotFound(false); + setIsMounting(true); if (mounter.unmountBeforeMounting) { unmount(); } const mount = async () => { - unmountRef.current = - (await mounter.mount({ - appBasePath: mounter.appBasePath, - history: createScopedHistory(appPath), - element: elementRef.current!, - onAppLeave: handler => setAppLeaveHandler(appId, handler), - })) || null; + setShowSpinner(true); + try { + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + history: createScopedHistory(appPath), + element: elementRef.current!, + onAppLeave: handler => setAppLeaveHandler(appId, handler), + })) || null; + } catch (e) { + // TODO: add error UI + } finally { + setShowSpinner(false); + setIsMounting(false); + } }; mount(); return unmount; - }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath]); + }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]); return ( {appNotFound && } + {showSpinner && ( +
+ +
+ )}
); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 4c135c576906765..ea7c5c9308fe2a9 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -32,6 +32,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setIsMounting: (isMounting: boolean) => void; } interface Params { @@ -43,6 +44,7 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, appStatuses$, + setIsMounting, }) => { const appStatuses = useObservable(appStatuses$, new Map()); const createScopedHistory = useMemo( @@ -67,7 +69,7 @@ export const AppRouter: FunctionComponent = ({ appPath={url} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler }} + {...{ appId, mounter, setAppLeaveHandler, setIsMounting }} /> )} />, @@ -92,7 +94,7 @@ export const AppRouter: FunctionComponent = ({ appId={id} appStatus={appStatuses.get(id) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler }} + {...{ mounter, setAppLeaveHandler, setIsMounting }} /> ); }} diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b609b2ce1d74155..444430175d4f23d 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,6 +91,7 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; + ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/renovate/package_groups.ts b/src/dev/renovate/package_groups.ts index 1bc65fd149f4716..9f5aa8556ac2140 100644 --- a/src/dev/renovate/package_groups.ts +++ b/src/dev/renovate/package_groups.ts @@ -159,7 +159,17 @@ export const RENOVATE_PACKAGE_GROUPS: PackageGroup[] = [ { name: 'hapi', packageWords: ['hapi'], - packageNames: ['hapi', 'joi', 'boom', 'hoek', 'h2o2', '@elastic/good', 'good-squeeze', 'inert'], + packageNames: [ + 'hapi', + 'joi', + 'boom', + 'hoek', + 'h2o2', + '@elastic/good', + 'good-squeeze', + 'inert', + 'accept', + ], }, { diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 4dc930dae3e2516..0e91f0a214a45a4 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,12 +18,13 @@ */ export const storybookAliases = { + advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', + dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/plugins/siem/scripts/storybook.js', - ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/azure_metrics/screenshot.png b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/azure_metrics/screenshot.png deleted file mode 100644 index 22136049b494ad9..000000000000000 Binary files a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/azure_metrics/screenshot.png and /dev/null differ diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/azure.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/azure.svg deleted file mode 100644 index a93c83b4b4ae007..000000000000000 --- a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/azure.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/oracle.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/oracle.svg deleted file mode 100644 index 78db57f91481894..000000000000000 --- a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/oracle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/optimize/bundles_route/dynamic_asset_response.ts b/src/optimize/bundles_route/dynamic_asset_response.ts index a020c6935eeecf2..2f5395341abb108 100644 --- a/src/optimize/bundles_route/dynamic_asset_response.ts +++ b/src/optimize/bundles_route/dynamic_asset_response.ts @@ -21,6 +21,7 @@ import Fs from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; +import Accept from 'accept'; import Boom from 'boom'; import Hapi from 'hapi'; @@ -37,6 +38,41 @@ const asyncOpen = promisify(Fs.open); const asyncClose = promisify(Fs.close); const asyncFstat = promisify(Fs.fstat); +async function tryToOpenFile(filePath: string) { + try { + return await asyncOpen(filePath, 'r'); + } catch (e) { + if (e.code === 'ENOENT') { + return undefined; + } else { + throw e; + } + } +} + +async function selectCompressedFile(acceptEncodingHeader: string | undefined, path: string) { + let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; + + const supportedEncodings = Accept.encodings(acceptEncodingHeader, ['br', 'gzip']); + + if (supportedEncodings[0] === 'br') { + fileEncoding = 'br'; + fd = await tryToOpenFile(`${path}.br`); + } + if (!fd && supportedEncodings.includes('gzip')) { + fileEncoding = 'gzip'; + fd = await tryToOpenFile(`${path}.gz`); + } + if (!fd) { + fileEncoding = undefined; + // Use raw open to trigger exception if it does not exist + fd = await asyncOpen(path, 'r'); + } + + return { fd, fileEncoding }; +} + /** * Create a Hapi response for the requested path. This is designed * to replicate a subset of the features provided by Hapi's Inert @@ -74,6 +110,7 @@ export async function createDynamicAssetResponse({ isDist: boolean; }) { let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; try { const path = resolve(bundlesPath, request.params.path); @@ -86,7 +123,7 @@ export async function createDynamicAssetResponse({ // we use and manage a file descriptor mostly because // that's what Inert does, and since we are accessing // the file 2 or 3 times per request it seems logical - fd = await asyncOpen(path, 'r'); + ({ fd, fileEncoding } = await selectCompressedFile(request.headers['accept-encoding'], path)); const stat = await asyncFstat(fd); const hash = isDist ? undefined : await getFileHash(fileHashCache, path, stat, fd); @@ -113,6 +150,12 @@ export async function createDynamicAssetResponse({ response.header('cache-control', 'must-revalidate'); } + // If we manually selected a compressed file, specify the encoding header. + // Otherwise, let Hapi automatically gzip the response. + if (fileEncoding) { + response.header('content-encoding', fileEncoding); + } + return response; } catch (error) { if (fd) { diff --git a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx index 4d15e7e899fa89d..ff4e50ba8c32753 100644 --- a/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/clone_panel_action.tsx @@ -39,7 +39,7 @@ export interface ClonePanelActionContext { export class ClonePanelAction implements ActionByType { public readonly type = ACTION_CLONE_PANEL; public readonly id = ACTION_CLONE_PANEL; - public order = 11; + public order = 45; constructor(private core: CoreStart) {} diff --git a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx index ddc255295e89b6f..5526af2f83850cf 100644 --- a/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/application/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 11; + public order = 3; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 5dab21ff671b42d..40231de7597f17d 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -46,7 +46,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory((() => null) as any, {} as any) diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 7de054f2eaa9c88..b28822120b31e69 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -136,7 +136,7 @@ export class DashboardPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -310,11 +310,11 @@ export class DashboardPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction.id); const clonePanelAction = new ClonePanelAction(core); uiActions.registerAction(clonePanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, diff --git a/src/plugins/data/public/actions/apply_filter_action.ts b/src/plugins/data/public/actions/apply_filter_action.ts index bd20c6f632a3a57..ebaac6b745bec15 100644 --- a/src/plugins/data/public/actions/apply_filter_action.ts +++ b/src/plugins/data/public/actions/apply_filter_action.ts @@ -42,6 +42,7 @@ export function createFilterAction( return createAction({ type: ACTION_GLOBAL_APPLY_FILTER, id: ACTION_GLOBAL_APPLY_FILTER, + getIconType: () => 'filter', getDisplayName: () => { return i18n.translate('data.filter.applyFilterActionTitle', { defaultMessage: 'Apply filter to current view', diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 75deff23ce20d69..ebc794ed7e5953b 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -59,6 +59,7 @@ import { changeTimeFilter, mapAndFlattenFilters, extractTimeFilter, + convertRangeFilterToTimeRangeString, } from './query'; // Filter helpers namespace: @@ -96,6 +97,7 @@ export const esFilters = { onlyDisabledFiltersChanged, changeTimeFilter, + convertRangeFilterToTimeRangeString, mapAndFlattenFilters, extractTimeFilter, }; diff --git a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts index b5d66a6aab60a03..73d5aeaf30710f3 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.ts @@ -37,10 +37,14 @@ const indexPatternCache = createIndexPatternCache(); type IndexPatternCachedFieldType = 'id' | 'title'; +export interface IndexPatternSavedObjectAttrs { + title: string; +} + export class IndexPatternsService { private config: IUiSettingsClient; private savedObjectsClient: SavedObjectsClientContract; - private savedObjectsCache?: Array>> | null; + private savedObjectsCache?: Array> | null; private apiClient: IndexPatternsApiClient; ensureDefaultIndexPattern: EnsureDefaultIndexPattern; @@ -53,7 +57,7 @@ export class IndexPatternsService { private async refreshSavedObjectsCache() { this.savedObjectsCache = ( - await this.savedObjectsClient.find>({ + await this.savedObjectsClient.find({ type: 'index-pattern', fields: ['title'], perPage: 10000, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index f3a88287313a06d..d822e96d0a129f1 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -126,12 +126,12 @@ export class DataPublicPlugin implements Plugin boolean; changeTimeFilter: typeof changeTimeFilter; + convertRangeFilterToTimeRangeString: typeof convertRangeFilterToTimeRangeString; mapAndFlattenFilters: (filters: import("../common").Filter[]) => import("../common").Filter[]; extractTimeFilter: typeof extractTimeFilter; }; @@ -1783,52 +1784,53 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/common/es_query/filters/match_all_filter.ts:28:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:135:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:236:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:375:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:377:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:378:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:137:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:179:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:238:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:377:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:379:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:380:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/timefilter/index.ts b/src/plugins/data/public/query/timefilter/index.ts index 034af03842ab8d1..a5885a59f60ede9 100644 --- a/src/plugins/data/public/query/timefilter/index.ts +++ b/src/plugins/data/public/query/timefilter/index.ts @@ -23,5 +23,5 @@ export * from './types'; export { Timefilter, TimefilterContract } from './timefilter'; export { TimeHistory, TimeHistoryContract } from './time_history'; export { getTime, calculateBounds } from './get_time'; -export { changeTimeFilter } from './lib/change_time_filter'; +export { changeTimeFilter, convertRangeFilterToTimeRangeString } from './lib/change_time_filter'; export { extractTimeFilter } from './lib/extract_time_filter'; diff --git a/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts b/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts index 8da83580ef5d6be..cbbf2f275431296 100644 --- a/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts +++ b/src/plugins/data/public/query/timefilter/lib/change_time_filter.ts @@ -20,7 +20,7 @@ import moment from 'moment'; import { keys } from 'lodash'; import { TimefilterContract } from '../../timefilter'; -import { RangeFilter } from '../../../../common'; +import { RangeFilter, TimeRange } from '../../../../common'; export function convertRangeFilterToTimeRange(filter: RangeFilter) { const key = keys(filter.range)[0]; @@ -32,6 +32,14 @@ export function convertRangeFilterToTimeRange(filter: RangeFilter) { }; } +export function convertRangeFilterToTimeRangeString(filter: RangeFilter): TimeRange { + const { from, to } = convertRangeFilterToTimeRange(filter); + return { + from: from?.toISOString(), + to: to?.toISOString(), + }; +} + export function changeTimeFilter(timeFilter: TimefilterContract, filter: RangeFilter) { timeFilter.setTime(convertRangeFilterToTimeRange(filter)); } diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index c8c4f0b95c45872..33cf210763b1002 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -31,12 +31,15 @@ import { ACTION_EDIT_PANEL, FilterActionContext, ACTION_APPLY_FILTER, + panelNotificationTrigger, + PANEL_NOTIFICATION_TRIGGER, } from './lib'; declare module '../../ui_actions/public' { export interface TriggerContextMapping { [CONTEXT_MENU_TRIGGER]: EmbeddableContext; [PANEL_BADGE_TRIGGER]: EmbeddableContext; + [PANEL_NOTIFICATION_TRIGGER]: EmbeddableContext; } export interface ActionContextMapping { @@ -56,6 +59,7 @@ declare module '../../ui_actions/public' { export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(contextMenuTrigger); uiActions.registerTrigger(panelBadgeTrigger); + uiActions.registerTrigger(panelNotificationTrigger); const actionApplyFilter = createFilterAction(); diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 5ee66f9d19ac00f..e61ad2a6eefedd4 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -23,23 +23,24 @@ import { PluginInitializerContext } from 'src/core/public'; import { EmbeddablePublicPlugin } from './plugin'; export { - Adapters, ACTION_ADD_PANEL, - AddPanelAction, ACTION_APPLY_FILTER, + ACTION_EDIT_PANEL, + Adapters, + AddPanelAction, Container, ContainerInput, ContainerOutput, CONTEXT_MENU_TRIGGER, contextMenuTrigger, - ACTION_EDIT_PANEL, + defaultEmbeddableFactoryProvider, EditPanelAction, Embeddable, EmbeddableChildPanel, EmbeddableChildPanelProps, EmbeddableContext, - EmbeddableFactoryDefinition, EmbeddableFactory, + EmbeddableFactoryDefinition, EmbeddableFactoryNotFoundError, EmbeddableFactoryRenderer, EmbeddableInput, @@ -57,6 +58,8 @@ export { OutputSpec, PANEL_BADGE_TRIGGER, panelBadgeTrigger, + PANEL_NOTIFICATION_TRIGGER, + panelNotificationTrigger, PanelNotFoundError, PanelState, PropertySpec, @@ -64,10 +67,17 @@ export { withEmbeddableSubscription, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, + isRangeSelectTriggerContext, + isValueClickTriggerContext, } from './lib'; export function plugin(initializerContext: PluginInitializerContext) { return new EmbeddablePublicPlugin(initializerContext); } -export { EmbeddableSetup, EmbeddableStart } from './plugin'; +export { + EmbeddableSetup, + EmbeddableStart, + EmbeddableSetupDependencies, + EmbeddableStartDependencies, +} from './plugin'; diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 0abbc25ff49a62f..d57867900c24b2b 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -34,7 +34,7 @@ interface ActionContext { export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 15; + public order = 50; constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index a135484ff61be02..9c544e86e189ab1 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,14 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { isEqual, cloneDeep } from 'lodash'; + +import { cloneDeep, isEqual } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters } from '../types'; +import { Adapters, ViewMode } from '../types'; import { IContainer } from '../containers'; -import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; -import { ViewMode } from '../types'; +import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { TriggerContextMapping } from '../ui_actions'; -import { EmbeddableActionStorage } from './embeddable_action_storage'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; @@ -33,6 +32,10 @@ export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { + static runtimeId: number = 0; + + public readonly runtimeId = Embeddable.runtimeId++; + public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -51,11 +54,6 @@ export abstract class Embeddable< // TODO: Rename to destroyed. private destoyed: boolean = false; - private __actionStorage?: EmbeddableActionStorage; - public get actionStorage(): EmbeddableActionStorage { - return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); - } - constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { this.id = input.id; this.output = { @@ -158,8 +156,10 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; + this.input$.complete(); this.output$.complete(); + if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts deleted file mode 100644 index 520f92840c5f9c0..000000000000000 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Embeddable } from '..'; - -/** - * Below two interfaces are here temporarily, they will move to `ui_actions` - * plugin once #58216 is merged. - */ -export interface SerializedEvent { - eventId: string; - triggerId: string; - action: unknown; -} -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; -} - -export class EmbeddableActionStorage implements ActionStorage { - constructor(private readonly embbeddable: Embeddable) {} - - async create(event: SerializedEvent) { - const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; - const exists = !!events.find(({ eventId }) => eventId === event.eventId); - - if (exists) { - throw new Error( - `[EEXIST]: Event with [eventId = ${event.eventId}] already exists on ` + - `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` - ); - } - - this.embbeddable.updateInput({ - ...input, - events: [...events, event], - }); - } - - async update(event: SerializedEvent) { - const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; - const index = events.findIndex(({ eventId }) => eventId === event.eventId); - - if (index === -1) { - throw new Error( - `[ENOENT]: Event with [eventId = ${event.eventId}] could not be ` + - `updated as it does not exist in ` + - `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` - ); - } - - this.embbeddable.updateInput({ - ...input, - events: [...events.slice(0, index), event, ...events.slice(index + 1)], - }); - } - - async remove(eventId: string) { - const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; - const index = events.findIndex(event => eventId === event.eventId); - - if (index === -1) { - throw new Error( - `[ENOENT]: Event with [eventId = ${eventId}] could not be ` + - `removed as it does not exist in ` + - `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` - ); - } - - this.embbeddable.updateInput({ - ...input, - events: [...events.slice(0, index), ...events.slice(index + 1)], - }); - } - - async read(eventId: string): Promise { - const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; - const event = events.find(ev => eventId === ev.eventId); - - if (!event) { - throw new Error( - `[ENOENT]: Event with [eventId = ${eventId}] could not be found in ` + - `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` - ); - } - - return event; - } - - private __list() { - const input = this.embbeddable.getInput(); - return (input.events || []) as SerializedEvent[]; - } - - async count(): Promise { - return this.__list().length; - } - - async list(): Promise { - return this.__list(); - } -} diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9a3e49e4979623e..c16698a5f8637bf 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -36,9 +36,9 @@ export interface EmbeddableInput { hidePanelTitles?: boolean; /** - * Reserved key for `ui_actions` events. + * Reserved key for enhancements added by other plugins. */ - events?: unknown; + enhancements?: unknown; /** * List of action IDs that this embeddable should not render. @@ -91,6 +91,19 @@ export interface IEmbeddable< **/ readonly id: string; + /** + * Unique ID an embeddable is assigned each time it is initialized. This ID + * is different for different instances of the same embeddable. For example, + * if the same dashboard is rendered twice on the screen, all embeddable + * instances will have a unique `runtimeId`. + */ + readonly runtimeId?: number; + + /** + * Extra abilities added to Embeddable by `*_enhanced` plugins. + */ + enhancements?: object; + /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 49b6d7803a2009e..9dd4c74c624d9bb 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -45,7 +45,7 @@ import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; import { embeddablePluginMock } from '../../mocks'; -const actionRegistry = new Map>(); +const actionRegistry = new Map(); const triggerRegistry = new Map(); const { setup, doStart } = embeddablePluginMock.createInstance(); @@ -214,13 +214,17 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action: Action = { + const action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return Promise.resolve(undefined); + }, }; const getActions = () => Promise.resolve([action]); @@ -246,13 +250,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action: Action = { + const action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return Promise.resolve(undefined); + }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c43359382a33d2c..36ddfb49b0312ea 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -25,7 +25,12 @@ import { CoreStart, OverlayStart } from '../../../../../core/public'; import { toMountPoint } from '../../../../kibana_react/public'; import { Start as InspectorStartContract } from '../inspector'; -import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, EmbeddableContext } from '../triggers'; +import { + CONTEXT_MENU_TRIGGER, + PANEL_BADGE_TRIGGER, + PANEL_NOTIFICATION_TRIGGER, + EmbeddableContext, +} from '../triggers'; import { IEmbeddable } from '../embeddables/i_embeddable'; import { ViewMode } from '../types'; @@ -38,6 +43,14 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; +const sortByOrderField = ( + { order: orderA }: { order?: number }, + { order: orderB }: { order?: number } +) => (orderB || 0) - (orderA || 0); + +const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => + disabledActions.indexOf(id) === -1; + interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -58,6 +71,7 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; + notifications: Array>; } export class EmbeddablePanel extends React.Component { @@ -83,6 +97,7 @@ export class EmbeddablePanel extends React.Component { hidePanelTitles, closeContextMenu: false, badges: [], + notifications: [], }; this.embeddableRoot = React.createRef(); @@ -104,6 +119,22 @@ export class EmbeddablePanel extends React.Component { }); } + private async refreshNotifications() { + let notifications = await this.props.getActions(PANEL_NOTIFICATION_TRIGGER, { + embeddable: this.props.embeddable, + }); + if (!this.mounted) return; + + const { disabledActions } = this.props.embeddable.getInput(); + if (disabledActions) { + notifications = notifications.filter(badge => disabledActions.indexOf(badge.id) === -1); + } + + this.setState({ + notifications, + }); + } + public UNSAFE_componentWillMount() { this.mounted = true; const { embeddable } = this.props; @@ -116,6 +147,7 @@ export class EmbeddablePanel extends React.Component { }); this.refreshBadges(); + this.refreshNotifications(); } }); @@ -127,6 +159,7 @@ export class EmbeddablePanel extends React.Component { }); this.refreshBadges(); + this.refreshNotifications(); } }); } @@ -176,6 +209,7 @@ export class EmbeddablePanel extends React.Component { closeContextMenu={this.state.closeContextMenu} title={title} badges={this.state.badges} + notifications={this.state.notifications} embeddable={this.props.embeddable} headerId={headerId} /> @@ -202,13 +236,14 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); + const removeDisabledActions = removeById(disabledActions); + regularActions = regularActions.filter(removeDisabledActions); } const createGetUserData = (overlays: OverlayStart) => @@ -247,16 +282,10 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory, this.props.application), ]; - const sorted = actions - .concat(extraActions) - .sort((a: Action, b: Action) => { - const bOrder = b.order || 0; - const aOrder = a.order || 0; - return bOrder - aOrder; - }); + const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sorted, + actions: sortedActions, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index c0e43c0538833b6..36957c3b7949184 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,15 +33,13 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 10; + public order = 40; - constructor(private readonly getDataFromUser: GetUserData) { - this.order = 10; - } + constructor(private readonly getDataFromUser: GetUserData) {} public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Customize panel', + defaultMessage: 'Edit panel title', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index d04f35715537cb7..ae9645767b267c3 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 10; + public order = 20; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index ee7948f3d6a4ae9..a6d4128f3f10675 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 5; + public order = 1; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 99516a1d21d6fb5..35a10ed848e838d 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,6 +23,7 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, + EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -38,6 +39,7 @@ export interface PanelHeaderProps { getActionContextMenuPanel: () => Promise; closeContextMenu: boolean; badges: Array>; + notifications: Array>; embeddable: IEmbeddable; headerId?: string; } @@ -56,6 +58,22 @@ function renderBadges(badges: Array>, embeddable: IEmb )); } +function renderNotifications( + notifications: Array>, + embeddable: IEmbeddable +) { + return notifications.map(notification => ( + notification.execute({ embeddable })} + > + {notification.getDisplayName({ embeddable })} + + )); +} + function renderTooltip(description: string) { return ( description !== '' && ( @@ -88,6 +106,7 @@ export function PanelHeader({ getActionContextMenuPanel, closeContextMenu, badges, + notifications, embeddable, headerId, }: PanelHeaderProps) { @@ -147,7 +166,7 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)} - + {renderNotifications(notifications, embeddable)} { + embeddable?: T; timeFieldName?: string; data: { data: Array<{ @@ -39,8 +39,12 @@ export interface ValueClickTriggerContext { }; } -export interface RangeSelectTriggerContext { - embeddable?: IEmbeddable; +export const isValueClickTriggerContext = ( + context: ValueClickTriggerContext | RangeSelectTriggerContext +): context is ValueClickTriggerContext => context.data && 'data' in context.data; + +export interface RangeSelectTriggerContext { + embeddable?: T; timeFieldName?: string; data: { table: KibanaDatatable; @@ -49,6 +53,10 @@ export interface RangeSelectTriggerContext { }; } +export const isRangeSelectTriggerContext = ( + context: ValueClickTriggerContext | RangeSelectTriggerContext +): context is RangeSelectTriggerContext => context.data && 'range' in context.data; + export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER'; export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = { id: CONTEXT_MENU_TRIGGER, @@ -60,5 +68,12 @@ export const PANEL_BADGE_TRIGGER = 'PANEL_BADGE_TRIGGER'; export const panelBadgeTrigger: Trigger<'PANEL_BADGE_TRIGGER'> = { id: PANEL_BADGE_TRIGGER, title: 'Panel badges', - description: 'Actions appear in title bar when an embeddable loads in a panel', + description: 'Actions appear in title bar when an embeddable loads in a panel.', +}; + +export const PANEL_NOTIFICATION_TRIGGER = 'PANEL_NOTIFICATION_TRIGGER'; +export const panelNotificationTrigger: Trigger<'PANEL_NOTIFICATION_TRIGGER'> = { + id: PANEL_NOTIFICATION_TRIGGER, + title: 'Panel notifications', + description: 'Actions appear in top-right corner of a panel.', }; diff --git a/src/plugins/embeddable/public/mocks.ts b/src/plugins/embeddable/public/mocks.ts index 65b15f3a7614fb4..f5487c381cfcb3a 100644 --- a/src/plugins/embeddable/public/mocks.ts +++ b/src/plugins/embeddable/public/mocks.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { EmbeddableStart, EmbeddableSetup } from '.'; +import { + EmbeddableStart, + EmbeddableSetup, + EmbeddableSetupDependencies, + EmbeddableStartDependencies, +} from '.'; import { EmbeddablePublicPlugin } from './plugin'; import { coreMock } from '../../../core/public/mocks'; @@ -45,14 +50,14 @@ const createStartContract = (): Start => { return startContract; }; -const createInstance = () => { +const createInstance = (setupPlugins: Partial = {}) => { const plugin = new EmbeddablePublicPlugin({} as any); const setup = plugin.setup(coreMock.createSetup(), { - uiActions: uiActionsPluginMock.createSetupContract(), + uiActions: setupPlugins.uiActions || uiActionsPluginMock.createSetupContract(), }); - const doStart = () => + const doStart = (startPlugins: Partial = {}) => plugin.start(coreMock.createStart(), { - uiActions: uiActionsPluginMock.createStartContract(), + uiActions: startPlugins.uiActions || uiActionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), }); return { diff --git a/src/plugins/expressions/common/expression_types/specs/filter.ts b/src/plugins/expressions/common/expression_types/specs/filter.ts index 01d6b8a603db68b..fc1c086e817c9aa 100644 --- a/src/plugins/expressions/common/expression_types/specs/filter.ts +++ b/src/plugins/expressions/common/expression_types/specs/filter.ts @@ -17,29 +17,31 @@ * under the License. */ -import { ExpressionTypeDefinition } from '../types'; - -const name = 'filter'; +import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types'; /** * Represents an object that is a Filter. */ -export interface Filter { - type?: string; - value?: string; - column?: string; - and: Filter[]; - to?: string; - from?: string; - query?: string | null; -} +export type ExpressionValueFilter = ExpressionValueBoxed< + 'filter', + { + filterType?: string; + value?: string; + column?: string; + and: ExpressionValueFilter[]; + to?: string; + from?: string; + query?: string | null; + } +>; -export const filter: ExpressionTypeDefinition = { - name, +export const filter: ExpressionTypeDefinition<'filter', ExpressionValueFilter> = { + name: 'filter', from: { null: () => { return { - type: name, + type: 'filter', + filterType: 'filter', // Any meta data you wish to pass along. meta: {}, // And filters. If you need an "or", create a filter type for it. diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 6814764ee5faa1b..ee3fbd7a7b0b00d 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -78,7 +78,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index e41135b69392216..61d3838466bef35 100644 --- a/src/plugins/expressions/server/index.ts +++ b/src/plugins/expressions/server/index.ts @@ -69,7 +69,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/activemq_logs/screenshot.png b/src/plugins/home/public/assets/activemq_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/activemq_logs/screenshot.png rename to src/plugins/home/public/assets/activemq_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/apache_logs/screenshot.png b/src/plugins/home/public/assets/apache_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/apache_logs/screenshot.png rename to src/plugins/home/public/assets/apache_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/apache_metrics/screenshot.png b/src/plugins/home/public/assets/apache_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/apache_metrics/screenshot.png rename to src/plugins/home/public/assets/apache_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/auditbeat/screenshot.png b/src/plugins/home/public/assets/auditbeat/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/auditbeat/screenshot.png rename to src/plugins/home/public/assets/auditbeat/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/aws_logs/screenshot.png b/src/plugins/home/public/assets/aws_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/aws_logs/screenshot.png rename to src/plugins/home/public/assets/aws_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/aws_metrics/screenshot.png b/src/plugins/home/public/assets/aws_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/aws_metrics/screenshot.png rename to src/plugins/home/public/assets/aws_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/cisco_logs/screenshot.png b/src/plugins/home/public/assets/cisco_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/cisco_logs/screenshot.png rename to src/plugins/home/public/assets/cisco_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/cockroachdb_metrics/screenshot.png b/src/plugins/home/public/assets/cockroachdb_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/cockroachdb_metrics/screenshot.png rename to src/plugins/home/public/assets/cockroachdb_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/consul_metrics/screenshot.png b/src/plugins/home/public/assets/consul_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/consul_metrics/screenshot.png rename to src/plugins/home/public/assets/consul_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/coredns_logs/screenshot.jpg b/src/plugins/home/public/assets/coredns_logs/screenshot.jpg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/coredns_logs/screenshot.jpg rename to src/plugins/home/public/assets/coredns_logs/screenshot.jpg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/coredns_metrics/screenshot.png b/src/plugins/home/public/assets/coredns_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/coredns_metrics/screenshot.png rename to src/plugins/home/public/assets/coredns_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/couchdb_metrics/screenshot.png b/src/plugins/home/public/assets/couchdb_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/couchdb_metrics/screenshot.png rename to src/plugins/home/public/assets/couchdb_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/docker_metrics/screenshot.png b/src/plugins/home/public/assets/docker_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/docker_metrics/screenshot.png rename to src/plugins/home/public/assets/docker_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/envoyproxy_logs/screenshot.png b/src/plugins/home/public/assets/envoyproxy_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/envoyproxy_logs/screenshot.png rename to src/plugins/home/public/assets/envoyproxy_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png b/src/plugins/home/public/assets/ibmmq_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png rename to src/plugins/home/public/assets/ibmmq_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_metrics/screenshot.png b/src/plugins/home/public/assets/ibmmq_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_metrics/screenshot.png rename to src/plugins/home/public/assets/ibmmq_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/iis_logs/screenshot.png b/src/plugins/home/public/assets/iis_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/iis_logs/screenshot.png rename to src/plugins/home/public/assets/iis_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/iptables_logs/screenshot.png b/src/plugins/home/public/assets/iptables_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/iptables_logs/screenshot.png rename to src/plugins/home/public/assets/iptables_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/kafka_logs/screenshot.png b/src/plugins/home/public/assets/kafka_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/kafka_logs/screenshot.png rename to src/plugins/home/public/assets/kafka_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/kubernetes_metrics/screenshot.png b/src/plugins/home/public/assets/kubernetes_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/kubernetes_metrics/screenshot.png rename to src/plugins/home/public/assets/kubernetes_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/activemq.svg b/src/plugins/home/public/assets/logos/activemq.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/activemq.svg rename to src/plugins/home/public/assets/logos/activemq.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/cisco.svg b/src/plugins/home/public/assets/logos/cisco.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/cisco.svg rename to src/plugins/home/public/assets/logos/cisco.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/cockroachdb.svg b/src/plugins/home/public/assets/logos/cockroachdb.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/cockroachdb.svg rename to src/plugins/home/public/assets/logos/cockroachdb.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/consul.svg b/src/plugins/home/public/assets/logos/consul.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/consul.svg rename to src/plugins/home/public/assets/logos/consul.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/coredns.svg b/src/plugins/home/public/assets/logos/coredns.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/coredns.svg rename to src/plugins/home/public/assets/logos/coredns.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/couchdb.svg b/src/plugins/home/public/assets/logos/couchdb.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/couchdb.svg rename to src/plugins/home/public/assets/logos/couchdb.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/envoyproxy.svg b/src/plugins/home/public/assets/logos/envoyproxy.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/envoyproxy.svg rename to src/plugins/home/public/assets/logos/envoyproxy.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg b/src/plugins/home/public/assets/logos/ibmmq.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg rename to src/plugins/home/public/assets/logos/ibmmq.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/iis.svg b/src/plugins/home/public/assets/logos/iis.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/iis.svg rename to src/plugins/home/public/assets/logos/iis.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/mssql.svg b/src/plugins/home/public/assets/logos/mssql.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/mssql.svg rename to src/plugins/home/public/assets/logos/mssql.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/munin.svg b/src/plugins/home/public/assets/logos/munin.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/munin.svg rename to src/plugins/home/public/assets/logos/munin.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/nats.svg b/src/plugins/home/public/assets/logos/nats.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/nats.svg rename to src/plugins/home/public/assets/logos/nats.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg b/src/plugins/home/public/assets/logos/openmetrics.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg rename to src/plugins/home/public/assets/logos/openmetrics.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg b/src/plugins/home/public/assets/logos/stan.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/stan.svg rename to src/plugins/home/public/assets/logos/stan.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/statsd.svg b/src/plugins/home/public/assets/logos/statsd.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/statsd.svg rename to src/plugins/home/public/assets/logos/statsd.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/suricata.svg b/src/plugins/home/public/assets/logos/suricata.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/suricata.svg rename to src/plugins/home/public/assets/logos/suricata.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/system.svg b/src/plugins/home/public/assets/logos/system.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/system.svg rename to src/plugins/home/public/assets/logos/system.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/traefik.svg b/src/plugins/home/public/assets/logos/traefik.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/traefik.svg rename to src/plugins/home/public/assets/logos/traefik.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ubiquiti.svg b/src/plugins/home/public/assets/logos/ubiquiti.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ubiquiti.svg rename to src/plugins/home/public/assets/logos/ubiquiti.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/uwsgi.svg b/src/plugins/home/public/assets/logos/uwsgi.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/uwsgi.svg rename to src/plugins/home/public/assets/logos/uwsgi.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/vsphere.svg b/src/plugins/home/public/assets/logos/vsphere.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/vsphere.svg rename to src/plugins/home/public/assets/logos/vsphere.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/zeek.svg b/src/plugins/home/public/assets/logos/zeek.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/zeek.svg rename to src/plugins/home/public/assets/logos/zeek.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/zookeeper.svg b/src/plugins/home/public/assets/logos/zookeeper.svg similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/zookeeper.svg rename to src/plugins/home/public/assets/logos/zookeeper.svg diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logstash_logs/screenshot.png b/src/plugins/home/public/assets/logstash_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/logstash_logs/screenshot.png rename to src/plugins/home/public/assets/logstash_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/mongodb_metrics/screenshot.png b/src/plugins/home/public/assets/mongodb_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/mongodb_metrics/screenshot.png rename to src/plugins/home/public/assets/mongodb_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/mssql_metrics/screenshot.png b/src/plugins/home/public/assets/mssql_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/mssql_metrics/screenshot.png rename to src/plugins/home/public/assets/mssql_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/mysql_logs/screenshot.png b/src/plugins/home/public/assets/mysql_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/mysql_logs/screenshot.png rename to src/plugins/home/public/assets/mysql_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/mysql_metrics/screenshot.png b/src/plugins/home/public/assets/mysql_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/mysql_metrics/screenshot.png rename to src/plugins/home/public/assets/mysql_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/nats_logs/screenshot.png b/src/plugins/home/public/assets/nats_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/nats_logs/screenshot.png rename to src/plugins/home/public/assets/nats_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/nats_metrics/screenshot.png b/src/plugins/home/public/assets/nats_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/nats_metrics/screenshot.png rename to src/plugins/home/public/assets/nats_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/nginx_logs/screenshot.png b/src/plugins/home/public/assets/nginx_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/nginx_logs/screenshot.png rename to src/plugins/home/public/assets/nginx_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/nginx_metrics/screenshot.png b/src/plugins/home/public/assets/nginx_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/nginx_metrics/screenshot.png rename to src/plugins/home/public/assets/nginx_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/osquery_logs/screenshot.png b/src/plugins/home/public/assets/osquery_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/osquery_logs/screenshot.png rename to src/plugins/home/public/assets/osquery_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/postgresql_logs/screenshot.png b/src/plugins/home/public/assets/postgresql_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/postgresql_logs/screenshot.png rename to src/plugins/home/public/assets/postgresql_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/rabbitmq_metrics/screenshot.png b/src/plugins/home/public/assets/rabbitmq_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/rabbitmq_metrics/screenshot.png rename to src/plugins/home/public/assets/rabbitmq_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/redis_logs/screenshot.png b/src/plugins/home/public/assets/redis_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/redis_logs/screenshot.png rename to src/plugins/home/public/assets/redis_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/redis_metrics/screenshot.png b/src/plugins/home/public/assets/redis_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/redis_metrics/screenshot.png rename to src/plugins/home/public/assets/redis_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/redisenterprise_metrics/screenshot.png b/src/plugins/home/public/assets/redisenterprise_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/redisenterprise_metrics/screenshot.png rename to src/plugins/home/public/assets/redisenterprise_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/stan_metrics/screenshot.png b/src/plugins/home/public/assets/stan_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/stan_metrics/screenshot.png rename to src/plugins/home/public/assets/stan_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/suricata_logs/screenshot.png b/src/plugins/home/public/assets/suricata_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/suricata_logs/screenshot.png rename to src/plugins/home/public/assets/suricata_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/system_logs/screenshot.png b/src/plugins/home/public/assets/system_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/system_logs/screenshot.png rename to src/plugins/home/public/assets/system_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/system_metrics/screenshot.png b/src/plugins/home/public/assets/system_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/system_metrics/screenshot.png rename to src/plugins/home/public/assets/system_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/traefik_logs/screenshot.png b/src/plugins/home/public/assets/traefik_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/traefik_logs/screenshot.png rename to src/plugins/home/public/assets/traefik_logs/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/uptime_monitors/screenshot.png b/src/plugins/home/public/assets/uptime_monitors/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/uptime_monitors/screenshot.png rename to src/plugins/home/public/assets/uptime_monitors/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/uwsgi_metrics/screenshot.png b/src/plugins/home/public/assets/uwsgi_metrics/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/uwsgi_metrics/screenshot.png rename to src/plugins/home/public/assets/uwsgi_metrics/screenshot.png diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/zeek_logs/screenshot.png b/src/plugins/home/public/assets/zeek_logs/screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/zeek_logs/screenshot.png rename to src/plugins/home/public/assets/zeek_logs/screenshot.png diff --git a/src/plugins/home/server/tutorials/activemq_logs/index.ts b/src/plugins/home/server/tutorials/activemq_logs/index.ts index 6511a21b15c442c..e85100996d4a165 100644 --- a/src/plugins/home/server/tutorials/activemq_logs/index.ts +++ b/src/plugins/home/server/tutorials/activemq_logs/index.ts @@ -48,7 +48,7 @@ export function activemqLogsSpecProvider(context: TutorialContext): TutorialSche learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-activemq.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/activemq.svg', + euiIconType: '/plugins/home/assets/logos/activemq.svg', artifacts: { dashboards: [ { @@ -64,7 +64,7 @@ export function activemqLogsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/activemq_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/activemq_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/activemq_metrics/index.ts b/src/plugins/home/server/tutorials/activemq_metrics/index.ts index 3898e2b5338b1d9..b477e65017ed363 100644 --- a/src/plugins/home/server/tutorials/activemq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/activemq_metrics/index.ts @@ -48,7 +48,7 @@ export function activemqMetricsSpecProvider(context: TutorialContext): TutorialS learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-activemq.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/activemq.svg', + euiIconType: '/plugins/home/assets/logos/activemq.svg', isBeta: true, artifacts: { application: { diff --git a/src/plugins/home/server/tutorials/apache_logs/index.ts b/src/plugins/home/server/tutorials/apache_logs/index.ts index adf94f55670960f..434f0b0b83f98ba 100644 --- a/src/plugins/home/server/tutorials/apache_logs/index.ts +++ b/src/plugins/home/server/tutorials/apache_logs/index.ts @@ -65,7 +65,7 @@ export function apacheLogsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/apache_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/apache_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/apache_metrics/index.ts b/src/plugins/home/server/tutorials/apache_metrics/index.ts index e272f3efb5abe99..1521c9820c400e5 100644 --- a/src/plugins/home/server/tutorials/apache_metrics/index.ts +++ b/src/plugins/home/server/tutorials/apache_metrics/index.ts @@ -64,7 +64,7 @@ export function apacheMetricsSpecProvider(context: TutorialContext): TutorialSch }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/apache_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/apache_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/auditbeat/index.ts b/src/plugins/home/server/tutorials/auditbeat/index.ts index 6d94e7507ff4201..dadbf913d5ed5ad 100644 --- a/src/plugins/home/server/tutorials/auditbeat/index.ts +++ b/src/plugins/home/server/tutorials/auditbeat/index.ts @@ -63,7 +63,7 @@ processes, users, logins, sockets information, file accesses, and more. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/auditbeat/screenshot.png', + previewImagePath: '/plugins/home/assets/auditbeat/screenshot.png', onPrem: onPremInstructions(platforms, context), elasticCloud: cloudInstructions(platforms), onPremElasticCloud: onPremCloudInstructions(platforms), diff --git a/src/plugins/home/server/tutorials/aws_logs/index.ts b/src/plugins/home/server/tutorials/aws_logs/index.ts index 8908838bd558abc..2fa22fa2c2d7000 100644 --- a/src/plugins/home/server/tutorials/aws_logs/index.ts +++ b/src/plugins/home/server/tutorials/aws_logs/index.ts @@ -65,7 +65,7 @@ export function awsLogsSpecProvider(context: TutorialContext): TutorialSchema { }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/aws_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/aws_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/aws_metrics/index.ts b/src/plugins/home/server/tutorials/aws_metrics/index.ts index d00951b524530c5..c52620e150b5fec 100644 --- a/src/plugins/home/server/tutorials/aws_metrics/index.ts +++ b/src/plugins/home/server/tutorials/aws_metrics/index.ts @@ -66,7 +66,7 @@ export function awsMetricsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/aws_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/aws_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/cisco_logs/index.ts b/src/plugins/home/server/tutorials/cisco_logs/index.ts index a6948026631712d..4514b61570b0701 100644 --- a/src/plugins/home/server/tutorials/cisco_logs/index.ts +++ b/src/plugins/home/server/tutorials/cisco_logs/index.ts @@ -50,7 +50,7 @@ supports the "asa" fileset for Cisco ASA firewall logs received over syslog or r learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-cisco.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/cisco.svg', + euiIconType: '/plugins/home/assets/logos/cisco.svg', artifacts: { dashboards: [], application: { @@ -64,7 +64,7 @@ supports the "asa" fileset for Cisco ASA firewall logs received over syslog or r }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/cisco_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/cisco_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts index 10f0eb3e4f34fdf..9d33d9bf786d012 100644 --- a/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts +++ b/src/plugins/home/server/tutorials/cloudwatch_logs/index.ts @@ -58,7 +58,6 @@ export function cloudwatchLogsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/uptime_monitors/screenshot.png', onPrem: onPremInstructions([], context), elasticCloud: cloudInstructions(), onPremElasticCloud: onPremCloudInstructions(), diff --git a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts index a8146e024a37e8b..96c02f24e347ae6 100644 --- a/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/cockroachdb_metrics/index.ts @@ -48,7 +48,7 @@ export function cockroachdbMetricsSpecProvider(context: TutorialContext): Tutori learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-cockroachdb.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/cockroachdb.svg', + euiIconType: '/plugins/home/assets/logos/cockroachdb.svg', artifacts: { dashboards: [ { @@ -67,7 +67,7 @@ export function cockroachdbMetricsSpecProvider(context: TutorialContext): Tutori }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/cockroachdb_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/cockroachdb_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/consul_metrics/index.ts b/src/plugins/home/server/tutorials/consul_metrics/index.ts index 8b12f38274ee9f9..8bf4333cb018f51 100644 --- a/src/plugins/home/server/tutorials/consul_metrics/index.ts +++ b/src/plugins/home/server/tutorials/consul_metrics/index.ts @@ -48,7 +48,7 @@ export function consulMetricsSpecProvider(context: TutorialContext): TutorialSch learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-consul.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/consul.svg', + euiIconType: '/plugins/home/assets/logos/consul.svg', artifacts: { dashboards: [ { @@ -64,7 +64,7 @@ export function consulMetricsSpecProvider(context: TutorialContext): TutorialSch }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/consul_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/consul_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/coredns_logs/index.ts b/src/plugins/home/server/tutorials/coredns_logs/index.ts index e2f976c0f377bc8..1c62366251661bd 100644 --- a/src/plugins/home/server/tutorials/coredns_logs/index.ts +++ b/src/plugins/home/server/tutorials/coredns_logs/index.ts @@ -50,7 +50,7 @@ export function corednsLogsSpecProvider(context: TutorialContext): TutorialSchem learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-coredns.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/coredns.svg', + euiIconType: '/plugins/home/assets/logos/coredns.svg', artifacts: { dashboards: [ { @@ -66,7 +66,7 @@ export function corednsLogsSpecProvider(context: TutorialContext): TutorialSchem }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/coredns_logs/screenshot.jpg', + previewImagePath: '/plugins/home/assets/coredns_logs/screenshot.jpg', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/coredns_metrics/index.ts b/src/plugins/home/server/tutorials/coredns_metrics/index.ts index ad0ce4a58c738b6..19db58e3456e7fc 100644 --- a/src/plugins/home/server/tutorials/coredns_metrics/index.ts +++ b/src/plugins/home/server/tutorials/coredns_metrics/index.ts @@ -48,7 +48,7 @@ export function corednsMetricsSpecProvider(context: TutorialContext): TutorialSc learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-coredns.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/coredns.svg', + euiIconType: '/plugins/home/assets/logos/coredns.svg', artifacts: { application: { label: i18n.translate('home.tutorials.corednsMetrics.artifacts.application.label', { @@ -62,7 +62,7 @@ export function corednsMetricsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/coredns_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/coredns_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts index e1423e96b1d4758..1fbaa448172262f 100644 --- a/src/plugins/home/server/tutorials/couchdb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/couchdb_metrics/index.ts @@ -48,7 +48,7 @@ export function couchdbMetricsSpecProvider(context: TutorialContext): TutorialSc learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-couchdb.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/couchdb.svg', + euiIconType: '/plugins/home/assets/logos/couchdb.svg', artifacts: { dashboards: [ { @@ -67,7 +67,7 @@ export function couchdbMetricsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/couchdb_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/couchdb_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/docker_metrics/index.ts b/src/plugins/home/server/tutorials/docker_metrics/index.ts index 4d9d0c9ee68d72b..8c603697c471361 100644 --- a/src/plugins/home/server/tutorials/docker_metrics/index.ts +++ b/src/plugins/home/server/tutorials/docker_metrics/index.ts @@ -64,7 +64,7 @@ export function dockerMetricsSpecProvider(context: TutorialContext): TutorialSch }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/docker_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/docker_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts index 53803a9358a1465..3d88cce36d7528e 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_logs/index.ts @@ -50,7 +50,7 @@ It supports both standalone deployment and Envoy proxy deployment in Kubernetes. learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-envoyproxy.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/envoyproxy.svg', + euiIconType: '/plugins/home/assets/logos/envoyproxy.svg', artifacts: { dashboards: [], application: { @@ -64,7 +64,7 @@ It supports both standalone deployment and Envoy proxy deployment in Kubernetes. }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/envoyproxy_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/envoyproxy_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts index d405e7791854677..adc7a494200c173 100644 --- a/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts +++ b/src/plugins/home/server/tutorials/envoyproxy_metrics/index.ts @@ -48,7 +48,7 @@ export function envoyproxyMetricsSpecProvider(context: TutorialContext): Tutoria learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-envoyproxy.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/envoyproxy.svg', + euiIconType: '/plugins/home/assets/logos/envoyproxy.svg', artifacts: { dashboards: [], exportedFields: { @@ -56,7 +56,6 @@ export function envoyproxyMetricsSpecProvider(context: TutorialContext): Tutoria }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/envoyproxy_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts index 9922cb0e6341edd..5739c03954def0d 100644 --- a/src/plugins/home/server/tutorials/ibmmq_logs/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_logs/index.ts @@ -48,7 +48,7 @@ export function ibmmqLogsSpecProvider(context: TutorialContext): TutorialSchema learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-ibmmq.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/ibmmq.svg', + euiIconType: '/plugins/home/assets/logos/ibmmq.svg', artifacts: { dashboards: [ { @@ -64,7 +64,7 @@ export function ibmmqLogsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/ibmmq_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/ibmmq_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts index 2055196f833b2d0..a6a1a9c6d3a0614 100644 --- a/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/ibmmq_metrics/index.ts @@ -48,7 +48,7 @@ export function ibmmqMetricsSpecProvider(context: TutorialContext): TutorialSche learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-ibmmq.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/ibmmq.svg', + euiIconType: '/plugins/home/assets/logos/ibmmq.svg', isBeta: true, artifacts: { application: { @@ -63,7 +63,7 @@ export function ibmmqMetricsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/ibmmq_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/ibmmq_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/iis_logs/index.ts b/src/plugins/home/server/tutorials/iis_logs/index.ts index 82ce098018e0b50..fee8d036db757e2 100644 --- a/src/plugins/home/server/tutorials/iis_logs/index.ts +++ b/src/plugins/home/server/tutorials/iis_logs/index.ts @@ -49,7 +49,7 @@ export function iisLogsSpecProvider(context: TutorialContext): TutorialSchema { learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-iis.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/iis.svg', + euiIconType: '/plugins/home/assets/logos/iis.svg', artifacts: { dashboards: [ { @@ -65,7 +65,7 @@ export function iisLogsSpecProvider(context: TutorialContext): TutorialSchema { }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/iis_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/iis_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/iptables_logs/index.ts b/src/plugins/home/server/tutorials/iptables_logs/index.ts index b29ab20cb6653e8..e72e0ef300e0419 100644 --- a/src/plugins/home/server/tutorials/iptables_logs/index.ts +++ b/src/plugins/home/server/tutorials/iptables_logs/index.ts @@ -52,7 +52,7 @@ number and the action performed on the traffic (allow/deny).. \ learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-iptables.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/ubiquiti.svg', + euiIconType: '/plugins/home/assets/logos/ubiquiti.svg', artifacts: { dashboards: [], application: { @@ -66,7 +66,7 @@ number and the action performed on the traffic (allow/deny).. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/iptables_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/iptables_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/kafka_logs/index.ts b/src/plugins/home/server/tutorials/kafka_logs/index.ts index 74aa1ef772c85a9..746e65b71008c13 100644 --- a/src/plugins/home/server/tutorials/kafka_logs/index.ts +++ b/src/plugins/home/server/tutorials/kafka_logs/index.ts @@ -65,7 +65,7 @@ export function kafkaLogsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/kafka_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/kafka_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts index 466f713d35e0619..bcea7f1221e1f10 100644 --- a/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts +++ b/src/plugins/home/server/tutorials/kubernetes_metrics/index.ts @@ -67,7 +67,7 @@ export function kubernetesMetricsSpecProvider(context: TutorialContext): Tutoria }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/kubernetes_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/kubernetes_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/logstash_logs/index.ts b/src/plugins/home/server/tutorials/logstash_logs/index.ts index 276ceedbbcc680f..69e498ac59459d0 100644 --- a/src/plugins/home/server/tutorials/logstash_logs/index.ts +++ b/src/plugins/home/server/tutorials/logstash_logs/index.ts @@ -65,7 +65,7 @@ export function logstashLogsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/logstash_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/logstash_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts index 1a10dc38494711b..f02695e207dd32c 100644 --- a/src/plugins/home/server/tutorials/mongodb_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mongodb_metrics/index.ts @@ -67,7 +67,7 @@ export function mongodbMetricsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/mongodb_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/mongodb_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/mssql_metrics/index.ts b/src/plugins/home/server/tutorials/mssql_metrics/index.ts index a1c994d670a3d46..4b418587f78b2c2 100644 --- a/src/plugins/home/server/tutorials/mssql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mssql_metrics/index.ts @@ -48,7 +48,7 @@ export function mssqlMetricsSpecProvider(context: TutorialContext): TutorialSche learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-mssql.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/mssql.svg', + euiIconType: '/plugins/home/assets/logos/mssql.svg', isBeta: false, artifacts: { dashboards: [ @@ -65,7 +65,7 @@ export function mssqlMetricsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/mssql_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/mssql_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/munin_metrics/index.ts b/src/plugins/home/server/tutorials/munin_metrics/index.ts index 90e4ac6026dadfa..1d6b19c4cec2e12 100644 --- a/src/plugins/home/server/tutorials/munin_metrics/index.ts +++ b/src/plugins/home/server/tutorials/munin_metrics/index.ts @@ -36,7 +36,7 @@ export function muninMetricsSpecProvider(context: TutorialContext): TutorialSche name: i18n.translate('home.tutorials.muninMetrics.nameTitle', { defaultMessage: 'Munin metrics', }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/munin.svg', + euiIconType: '/plugins/home/assets/logos/munin.svg', isBeta: true, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.muninMetrics.shortDescription', { diff --git a/src/plugins/home/server/tutorials/mysql_logs/index.ts b/src/plugins/home/server/tutorials/mysql_logs/index.ts index e003f4dfd47e481..178a371f9212ed2 100644 --- a/src/plugins/home/server/tutorials/mysql_logs/index.ts +++ b/src/plugins/home/server/tutorials/mysql_logs/index.ts @@ -65,7 +65,7 @@ export function mysqlLogsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/mysql_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/mysql_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/mysql_metrics/index.ts b/src/plugins/home/server/tutorials/mysql_metrics/index.ts index d18cc31512e7117..1148caeb441f88f 100644 --- a/src/plugins/home/server/tutorials/mysql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/mysql_metrics/index.ts @@ -64,7 +64,7 @@ export function mysqlMetricsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/mysql_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/mysql_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/nats_logs/index.ts b/src/plugins/home/server/tutorials/nats_logs/index.ts index 3f6cb36d8d49e3c..17c37755b6bc376 100644 --- a/src/plugins/home/server/tutorials/nats_logs/index.ts +++ b/src/plugins/home/server/tutorials/nats_logs/index.ts @@ -50,7 +50,7 @@ export function natsLogsSpecProvider(context: TutorialContext): TutorialSchema { learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-nats.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/nats.svg', + euiIconType: '/plugins/home/assets/logos/nats.svg', artifacts: { dashboards: [ { @@ -66,7 +66,7 @@ export function natsLogsSpecProvider(context: TutorialContext): TutorialSchema { }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/nats_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/nats_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/nats_metrics/index.ts b/src/plugins/home/server/tutorials/nats_metrics/index.ts index 27b5507ff6672e3..bce08e85c6977ef 100644 --- a/src/plugins/home/server/tutorials/nats_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nats_metrics/index.ts @@ -48,7 +48,7 @@ export function natsMetricsSpecProvider(context: TutorialContext): TutorialSchem learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-nats.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/nats.svg', + euiIconType: '/plugins/home/assets/logos/nats.svg', artifacts: { dashboards: [ { @@ -64,7 +64,7 @@ export function natsMetricsSpecProvider(context: TutorialContext): TutorialSchem }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/nats_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/nats_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/nginx_logs/index.ts b/src/plugins/home/server/tutorials/nginx_logs/index.ts index 756d4a171d858ac..37d0cc106bfe58c 100644 --- a/src/plugins/home/server/tutorials/nginx_logs/index.ts +++ b/src/plugins/home/server/tutorials/nginx_logs/index.ts @@ -65,7 +65,7 @@ export function nginxLogsSpecProvider(context: TutorialContext): TutorialSchema }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/nginx_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/nginx_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/nginx_metrics/index.ts b/src/plugins/home/server/tutorials/nginx_metrics/index.ts index 82af4d6c42dd861..8671f7218ffc8da 100644 --- a/src/plugins/home/server/tutorials/nginx_metrics/index.ts +++ b/src/plugins/home/server/tutorials/nginx_metrics/index.ts @@ -69,7 +69,7 @@ which must be enabled in your Nginx installation. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/nginx_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/nginx_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts index b0ff61c7116ce0e..eb539e15c1bcd93 100644 --- a/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts +++ b/src/plugins/home/server/tutorials/openmetrics_metrics/index.ts @@ -48,7 +48,7 @@ export function openmetricsMetricsSpecProvider(context: TutorialContext): Tutori learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-openmetrics.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/openmetrics.svg', + euiIconType: '/plugins/home/assets/logos/openmetrics.svg', artifacts: { dashboards: [], exportedFields: { diff --git a/src/plugins/home/server/tutorials/osquery_logs/index.ts b/src/plugins/home/server/tutorials/osquery_logs/index.ts index bce928519f66d5e..34a1b9e7f619dfc 100644 --- a/src/plugins/home/server/tutorials/osquery_logs/index.ts +++ b/src/plugins/home/server/tutorials/osquery_logs/index.ts @@ -65,7 +65,7 @@ export function osqueryLogsSpecProvider(context: TutorialContext): TutorialSchem }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/osquery_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/osquery_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts index a6c98fb16671fe5..975b549c9520b36 100644 --- a/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts +++ b/src/plugins/home/server/tutorials/php_fpm_metrics/index.ts @@ -63,7 +63,6 @@ export function phpfpmMetricsSpecProvider(context: TutorialContext): TutorialSch }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/php_fpm_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/postgresql_logs/index.ts b/src/plugins/home/server/tutorials/postgresql_logs/index.ts index def9f71c9d2df93..0c280619858196e 100644 --- a/src/plugins/home/server/tutorials/postgresql_logs/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_logs/index.ts @@ -68,7 +68,7 @@ export function postgresqlLogsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/postgresql_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/postgresql_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts index b16267aeb0de65a..f9bb9d249e755dc 100644 --- a/src/plugins/home/server/tutorials/postgresql_metrics/index.ts +++ b/src/plugins/home/server/tutorials/postgresql_metrics/index.ts @@ -65,7 +65,6 @@ export function postgresqlMetricsSpecProvider(context: TutorialContext): Tutoria }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/postgresql_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts index b62a7ff73142041..a646068e4ff3418 100644 --- a/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts +++ b/src/plugins/home/server/tutorials/rabbitmq_metrics/index.ts @@ -68,7 +68,7 @@ export function rabbitmqMetricsSpecProvider(context: TutorialContext): TutorialS }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/rabbitmq_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/rabbitmq_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/redis_logs/index.ts b/src/plugins/home/server/tutorials/redis_logs/index.ts index 27c288ce9c381b3..e017fae0499a3a4 100644 --- a/src/plugins/home/server/tutorials/redis_logs/index.ts +++ b/src/plugins/home/server/tutorials/redis_logs/index.ts @@ -71,7 +71,7 @@ Note that the `slowlog` fileset is experimental. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/redis_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/redis_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/redis_metrics/index.ts b/src/plugins/home/server/tutorials/redis_metrics/index.ts index 27c7780653168bf..bcc4d9bb0b67b91 100644 --- a/src/plugins/home/server/tutorials/redis_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redis_metrics/index.ts @@ -64,7 +64,7 @@ export function redisMetricsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/redis_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/redis_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts index b352691f06afe56..2c2246b15d7fa71 100644 --- a/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts +++ b/src/plugins/home/server/tutorials/redisenterprise_metrics/index.ts @@ -63,8 +63,7 @@ export function redisenterpriseMetricsSpecProvider(context: TutorialContext): Tu }, }, completionTimeMinutes: 10, - previewImagePath: - '/plugins/kibana/home/tutorial_resources/redisenterprise_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/redisenterprise_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/stan_metrics/index.ts b/src/plugins/home/server/tutorials/stan_metrics/index.ts index 7dd949704d3cfce..616bc7450249e46 100644 --- a/src/plugins/home/server/tutorials/stan_metrics/index.ts +++ b/src/plugins/home/server/tutorials/stan_metrics/index.ts @@ -48,7 +48,7 @@ export function stanMetricsSpecProvider(context: TutorialContext): TutorialSchem learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-stan.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/stan.svg', + euiIconType: '/plugins/home/assets/logos/stan.svg', artifacts: { dashboards: [ { @@ -64,7 +64,7 @@ export function stanMetricsSpecProvider(context: TutorialContext): TutorialSchem }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/stan_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/stan_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/statsd_metrics/index.ts b/src/plugins/home/server/tutorials/statsd_metrics/index.ts index c1d4a354e949670..1dc297e78c791f7 100644 --- a/src/plugins/home/server/tutorials/statsd_metrics/index.ts +++ b/src/plugins/home/server/tutorials/statsd_metrics/index.ts @@ -45,7 +45,7 @@ export function statsdMetricsSpecProvider(context: TutorialContext): TutorialSch learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-statsd.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/statsd.svg', + euiIconType: '/plugins/home/assets/logos/statsd.svg', artifacts: { dashboards: [], exportedFields: { diff --git a/src/plugins/home/server/tutorials/suricata_logs/index.ts b/src/plugins/home/server/tutorials/suricata_logs/index.ts index a3812fda147f53e..c02cb05889ebb61 100644 --- a/src/plugins/home/server/tutorials/suricata_logs/index.ts +++ b/src/plugins/home/server/tutorials/suricata_logs/index.ts @@ -50,7 +50,7 @@ export function suricataLogsSpecProvider(context: TutorialContext): TutorialSche learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-suricata.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/suricata.svg', + euiIconType: '/plugins/home/assets/logos/suricata.svg', artifacts: { dashboards: [ { @@ -66,7 +66,7 @@ export function suricataLogsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/suricata_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/suricata_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/system_logs/index.ts b/src/plugins/home/server/tutorials/system_logs/index.ts index ab8184c1b32491d..9bad70699a6ed8b 100644 --- a/src/plugins/home/server/tutorials/system_logs/index.ts +++ b/src/plugins/home/server/tutorials/system_logs/index.ts @@ -50,7 +50,7 @@ Unix/Linux based distributions. This module is not available on Windows. \ learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-system.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/system.svg', + euiIconType: '/plugins/home/assets/logos/system.svg', artifacts: { dashboards: [ { @@ -66,7 +66,7 @@ Unix/Linux based distributions. This module is not available on Windows. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/system_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/system_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/system_metrics/index.ts b/src/plugins/home/server/tutorials/system_metrics/index.ts index 456804c51f838e4..ef1a84ecdbf10b2 100644 --- a/src/plugins/home/server/tutorials/system_metrics/index.ts +++ b/src/plugins/home/server/tutorials/system_metrics/index.ts @@ -49,7 +49,7 @@ It collects system wide statistics and statistics per process and filesystem. \ learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-system.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/system.svg', + euiIconType: '/plugins/home/assets/logos/system.svg', artifacts: { dashboards: [ { @@ -65,7 +65,7 @@ It collects system wide statistics and statistics per process and filesystem. \ }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/system_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/system_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/traefik_logs/index.ts b/src/plugins/home/server/tutorials/traefik_logs/index.ts index 56f1d56ea01232a..1876edd6c0bf749 100644 --- a/src/plugins/home/server/tutorials/traefik_logs/index.ts +++ b/src/plugins/home/server/tutorials/traefik_logs/index.ts @@ -49,7 +49,7 @@ export function traefikLogsSpecProvider(context: TutorialContext): TutorialSchem learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-traefik.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/traefik.svg', + euiIconType: '/plugins/home/assets/logos/traefik.svg', artifacts: { dashboards: [ { @@ -65,7 +65,7 @@ export function traefikLogsSpecProvider(context: TutorialContext): TutorialSchem }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/traefik_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/traefik_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/traefik_metrics/index.ts b/src/plugins/home/server/tutorials/traefik_metrics/index.ts index 8fe81eca4c601ae..a97ee3ab9758a5b 100644 --- a/src/plugins/home/server/tutorials/traefik_metrics/index.ts +++ b/src/plugins/home/server/tutorials/traefik_metrics/index.ts @@ -45,7 +45,7 @@ export function traefikMetricsSpecProvider(context: TutorialContext): TutorialSc learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-traefik.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/traefik.svg', + euiIconType: '/plugins/home/assets/logos/traefik.svg', artifacts: { dashboards: [], exportedFields: { @@ -53,7 +53,6 @@ export function traefikMetricsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/traefik_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/uptime_monitors/index.ts b/src/plugins/home/server/tutorials/uptime_monitors/index.ts index 207bc0cb479be10..fa854a1c2350534 100644 --- a/src/plugins/home/server/tutorials/uptime_monitors/index.ts +++ b/src/plugins/home/server/tutorials/uptime_monitors/index.ts @@ -62,7 +62,7 @@ export function uptimeMonitorsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/uptime_monitors/screenshot.png', + previewImagePath: '/plugins/home/assets/uptime_monitors/screenshot.png', onPrem: onPremInstructions([], context), elasticCloud: cloudInstructions(), onPremElasticCloud: onPremCloudInstructions(), diff --git a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts index a1dfbc64ec24435..bbe4ea78ee87c10 100644 --- a/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts +++ b/src/plugins/home/server/tutorials/uwsgi_metrics/index.ts @@ -48,7 +48,7 @@ export function uwsgiMetricsSpecProvider(context: TutorialContext): TutorialSche learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-uwsgi.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/uwsgi.svg', + euiIconType: '/plugins/home/assets/logos/uwsgi.svg', isBeta: false, artifacts: { dashboards: [ @@ -65,7 +65,7 @@ export function uwsgiMetricsSpecProvider(context: TutorialContext): TutorialSche }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/uwsgi_metrics/screenshot.png', + previewImagePath: '/plugins/home/assets/uwsgi_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts index 908b6440f88c66d..81bf99f1ec3c18d 100644 --- a/src/plugins/home/server/tutorials/vsphere_metrics/index.ts +++ b/src/plugins/home/server/tutorials/vsphere_metrics/index.ts @@ -48,7 +48,7 @@ export function vSphereMetricsSpecProvider(context: TutorialContext): TutorialSc learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-vsphere.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/vsphere.svg', + euiIconType: '/plugins/home/assets/logos/vsphere.svg', isBeta: true, artifacts: { application: { @@ -63,7 +63,6 @@ export function vSphereMetricsSpecProvider(context: TutorialContext): TutorialSc }, }, completionTimeMinutes: 10, - // previewImagePath: '/plugins/kibana/home/tutorial_resources/vsphere_metrics/screenshot.png', onPrem: onPremInstructions(moduleName, context), elasticCloud: cloudInstructions(moduleName), onPremElasticCloud: onPremCloudInstructions(moduleName), diff --git a/src/plugins/home/server/tutorials/zeek_logs/index.ts b/src/plugins/home/server/tutorials/zeek_logs/index.ts index 251825147ded110..4bd54c96481b6bf 100644 --- a/src/plugins/home/server/tutorials/zeek_logs/index.ts +++ b/src/plugins/home/server/tutorials/zeek_logs/index.ts @@ -50,7 +50,7 @@ export function zeekLogsSpecProvider(context: TutorialContext): TutorialSchema { learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-zeek.html', }, }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/zeek.svg', + euiIconType: '/plugins/home/assets/logos/zeek.svg', artifacts: { dashboards: [ { @@ -66,7 +66,7 @@ export function zeekLogsSpecProvider(context: TutorialContext): TutorialSchema { }, }, completionTimeMinutes: 10, - previewImagePath: '/plugins/kibana/home/tutorial_resources/zeek_logs/screenshot.png', + previewImagePath: '/plugins/home/assets/zeek_logs/screenshot.png', onPrem: onPremInstructions(moduleName, platforms, context), elasticCloud: cloudInstructions(moduleName, platforms), onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), diff --git a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts index 581b4a14a2f3825..f74f65cbc6b7d5d 100644 --- a/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts +++ b/src/plugins/home/server/tutorials/zookeeper_metrics/index.ts @@ -36,7 +36,7 @@ export function zookeeperMetricsSpecProvider(context: TutorialContext): Tutorial name: i18n.translate('home.tutorials.zookeeperMetrics.nameTitle', { defaultMessage: 'Zookeeper metrics', }), - euiIconType: '/plugins/kibana/home/tutorial_resources/logos/zookeeper.svg', + euiIconType: '/plugins/home/assets/logos/zookeeper.svg', isBeta: false, category: TutorialsCategory.METRICS, shortDescription: i18n.translate('home.tutorials.zookeeperMetrics.shortDescription', { diff --git a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts index 36903f2d7c90f73..90823359359a186 100644 --- a/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/common/state_containers/create_state_container_react_helpers.ts @@ -24,15 +24,58 @@ import { Comparator, Connect, StateContainer, UnboxState } from './types'; const { useContext, useLayoutEffect, useRef, createElement: h } = React; +/** + * Returns the latest state of a state container. + * + * @param container State container which state to track. + */ +export const useContainerState = >( + container: Container +): UnboxState => useObservable(container.state$, container.get()); + +/** + * Apply selector to state container to extract only needed information. Will + * re-render your component only when the section changes. + * + * @param container State container which state to track. + * @param selector Function used to pick parts of state. + * @param comparator Comparator function used to memoize previous result, to not + * re-render React component if state did not change. By default uses + * `fast-deep-equal` package. + */ +export const useContainerSelector = , Result>( + container: Container, + selector: (state: UnboxState) => Result, + comparator: Comparator = defaultComparator +): Result => { + const { state$, get } = container; + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; +}; + export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const { state$, get } = useContainer(); - const value = useObservable(state$, get()); - return value; + const container = useContainer(); + return useContainerState(container); }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const { state$, get } = useContainer(); - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; + const container = useContainer(); + return useContainerSelector(container, selector, comparator); }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 26a29bc470e8ac3..29ffa4cd486b5c3 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object, + PureTransitions extends object = object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts new file mode 100644 index 000000000000000..14d6e52dc046509 --- /dev/null +++ b/src/plugins/kibana_utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createStateContainer, StateContainer, of } from './common'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index c634322b23d0b86..3d8a4414de70c89 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -74,8 +74,10 @@ export { StartSyncStateFnType, StopSyncStateFnType, } from './state_sync'; +export { Configurable, CollectConfigProps } from './ui'; export { removeQueryParam, redirectWhenMissing } from './history'; export { applyDiff } from './state_management/utils/diff_object'; +export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter'; /** dummy plugin, we just want kibanaUtils to have its own bundle */ export function plugin() { diff --git a/src/plugins/kibana_utils/public/ui/configurable.ts b/src/plugins/kibana_utils/public/ui/configurable.ts new file mode 100644 index 000000000000000..a4a9f09c1c0e099 --- /dev/null +++ b/src/plugins/kibana_utils/public/ui/configurable.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from '../../common/ui/ui_component'; + +/** + * Represents something that can be configured by user using UI. + */ +export interface Configurable { + /** + * Create default config for this item, used when item is created for the first time. + */ + readonly createConfig: () => Config; + + /** + * Is this config valid. Used to validate user's input before saving. + */ + readonly isConfigValid: (config: Config) => boolean; + + /** + * `UiComponent` to be rendered when collecting configuration for this item. + */ + readonly CollectConfig: UiComponent>; +} + +/** + * Props provided to `CollectConfig` component on every re-render. + */ +export interface CollectConfigProps { + /** + * Current (latest) config of the item. + */ + config: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; + + /** + * Context information about where component is being rendered. + */ + context: Context; +} diff --git a/src/plugins/kibana_utils/public/ui/index.ts b/src/plugins/kibana_utils/public/ui/index.ts new file mode 100644 index 000000000000000..54d47ac7e980f96 --- /dev/null +++ b/src/plugins/kibana_utils/public/ui/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './configurable'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index feaa1f6a60e2f0b..f5dbbc9f923acda 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,10 +19,12 @@ import { UiComponent } from 'src/plugins/kibana_utils/public'; import { ActionType, ActionContextMapping } from '../types'; +import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action { +export interface Action + extends Partial> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -63,14 +65,30 @@ export interface Action { isCompatible(context: Context): Promise; /** - * If this returns something truthy, this will be used as [href] attribute on a link if possible (e.g. in context menu item) - * to support right click -> open in a new tab behavior. - * For regular click navigation is prevented and `execute()` takes control. + * Executes the action. */ - getHref?(context: Context): Promise; + execute(context: Context): Promise; +} + +/** + * A convenience interface used to register an action. + */ +export interface ActionDefinition + extends Partial> { + /** + * ID of the action that uniquely identifies this action in the actions registry. + */ + readonly id: string; + + /** + * ID of the factory for this action. Used to construct dynamic actions. + */ + readonly type?: ActionType; /** * Executes the action. */ execute(context: Context): Promise; } + +export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/actions/action_definition.ts deleted file mode 100644 index 79fda78401abdf4..000000000000000 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { UiComponent } from 'src/plugins/kibana_utils/public'; -import { ActionType, ActionContextMapping } from '../types'; - -export interface ActionDefinition { - /** - * Determined the order when there is more than one action matched to a trigger. - * Higher numbers are displayed first. - */ - order?: number; - - /** - * A unique identifier for this action instance. - */ - id?: string; - - /** - * The action type is what determines the context shape. - */ - readonly type: T; - - /** - * Optional EUI icon type that can be displayed along with the title. - */ - getIconType?(context: ActionContextMapping[T]): string; - - /** - * Returns a title to be displayed to the user. - * @param context - */ - getDisplayName?(context: ActionContextMapping[T]): string; - - /** - * `UiComponent` to render when displaying this action as a context menu item. - * If not provided, `getDisplayName` will be used instead. - */ - MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; - - /** - * Returns a promise that resolves to true if this action is compatible given the context, - * otherwise resolves to false. - */ - isCompatible?(context: ActionContextMapping[T]): Promise; - - /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. - */ - getHref?(context: ActionContextMapping[T]): Promise; - - /** - * Executes the action. - */ - execute(context: ActionContextMapping[T]): Promise; -} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts new file mode 100644 index 000000000000000..b14346180c2741d --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.test.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ActionDefinition } from './action'; +import { ActionInternal } from './action_internal'; + +const defaultActionDef: ActionDefinition = { + id: 'test-action', + execute: jest.fn(), +}; + +describe('ActionInternal', () => { + test('can instantiate from action definition', () => { + const action = new ActionInternal(defaultActionDef); + expect(action.id).toBe('test-action'); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts new file mode 100644 index 000000000000000..4cbc4dd2a053c79 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Action, ActionContext as Context, ActionDefinition } from './action'; +import { Presentable } from '../util/presentable'; +import { uiToReactComponent } from '../../../kibana_react/public'; +import { ActionType } from '../types'; + +export class ActionInternal + implements Action>, Presentable> { + constructor(public readonly definition: A) {} + + public readonly id: string = this.definition.id; + public readonly type: ActionType = this.definition.type || ''; + public readonly order: number = this.definition.order || 0; + public readonly MenuItem? = this.definition.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public execute(context: Context) { + return this.definition.execute(context); + } + + public getIconType(context: Context): string | undefined { + if (!this.definition.getIconType) return undefined; + return this.definition.getIconType(context); + } + + public getDisplayName(context: Context): string { + if (!this.definition.getDisplayName) return `Action: ${this.id}`; + return this.definition.getDisplayName(context); + } + + public async isCompatible(context: Context): Promise { + if (!this.definition.isCompatible) return true; + return await this.definition.isCompatible(context); + } + + public async getHref(context: Context): Promise { + if (!this.definition.getHref) return undefined; + return await this.definition.getHref(context); + } +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index cc66f221e40827d..dea21678eccea6d 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,11 +17,19 @@ * under the License. */ +import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action_definition'; +import { ActionDefinition } from './action'; -export function createAction(action: ActionDefinition): ActionByType { +interface ActionDefinitionByType + extends Omit, 'id'> { + id?: string; +} + +export function createAction( + action: ActionDefinitionByType +): ActionByType { return { getIconType: () => undefined, order: 0, @@ -29,5 +37,5 @@ export function createAction(action: ActionDefinition): isCompatible: () => Promise.resolve(true), getDisplayName: () => '', ...action, - }; + } as ActionByType; } diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 64bfd368e3dfa1a..88e42ff2ec11315 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,5 +18,6 @@ */ export * from './action'; +export * from './action_internal'; export * from './create_action'; export * from './incompatible_action_error'; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index d26740ffdf033be..0c19d20ed1bdabc 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', +}); + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, + title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; + title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -44,9 +50,7 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title: i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', - }), + title, items: menuItems, }; } @@ -54,49 +58,41 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = []; - const promises = actions.map(async action => { + const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); + const promises = actions.map(async (action, index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items.push( - await convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }) - ); + items[index] = await convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }); }); await Promise.all(promises); - return items; + return items.filter(Boolean); } -/** - * - * @param {ContextMenuAction} action - * @param {Embeddable} embeddable - * @return {Promise} - */ -async function convertPanelActionToContextMenuItem({ +async function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: A; + action: Action; + actionContext: Context; closeMenu: () => void; }): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { diff --git a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx index 4d794618e85abe3..c723388c021e986 100644 --- a/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx +++ b/src/plugins/ui_actions/public/context_menu/open_context_menu.tsx @@ -149,7 +149,11 @@ export function openContextMenu( anchorPosition="downRight" withTitle > - + , container ); diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 49b6bd5e1769957..a9b413fb36542d6 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,8 +26,14 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { Action, createAction, IncompatibleActionError } from './actions'; +export { + Action, + ActionDefinition as UiActionsActionDefinition, + createAction, + IncompatibleActionError, +} from './actions'; export { buildContextMenuForActions } from './context_menu'; +export { Presentable as UiActionsPresentable } from './util'; export { Trigger, TriggerContext, diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index c1be6b262652545..3522ac4941ba0a4 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,10 +28,12 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), registerTrigger: jest.fn(), + unregisterAction: jest.fn(), }; return setupContract; }; @@ -39,16 +41,18 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - registerAction: jest.fn(), - registerTrigger: jest.fn(), - getAction: jest.fn(), + unregisterAction: jest.fn(), + addTriggerAction: jest.fn(), + clear: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), + fork: jest.fn(), + getAction: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - clear: jest.fn(), - fork: jest.fn(), + registerAction: jest.fn(), + registerTrigger: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 928e57937a9b5a8..71148656cbb1648 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,7 +23,12 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' + | 'addTriggerAction' + | 'attachAction' + | 'detachAction' + | 'registerAction' + | 'registerTrigger' + | 'unregisterAction' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index bdf71a25e6dbc31..45a1bdffa52adf0 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,7 +18,7 @@ */ import { UiActionsService } from './ui_actions_service'; -import { Action, createAction } from '../actions'; +import { Action, ActionInternal, createAction } from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -102,6 +102,21 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); + + test('return action instance', () => { + const service = new UiActionsService(); + const action = service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test' as ActionType, + }); + + expect(action).toBeInstanceOf(ActionInternal); + expect(action.id).toBe('test'); + }); }); describe('.getTriggerActions()', () => { @@ -139,13 +154,14 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.attachAction(FOO_TRIGGER, action1); + service.addTriggerAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - service.attachAction(FOO_TRIGGER, action2); + service.addTriggerAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -164,7 +180,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -178,7 +194,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.attachAction(MY_TRIGGER, helloWorldAction); + service.addTriggerAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -204,7 +220,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.attachAction(testTrigger.id, action); + service.addTriggerAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -288,7 +304,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -309,14 +325,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.attachAction(FOO_TRIGGER, testAction2); + service2.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -330,14 +346,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.attachAction(FOO_TRIGGER, testAction2); + service1.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -392,7 +408,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.attachAction(MY_TRIGGER, action); + service.addTriggerAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -400,7 +416,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action to a trigger', () => { + test('can detach an action from a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -413,7 +429,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.attachAction(trigger.id, action); + service.addTriggerAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -445,7 +461,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index f7718e63773f5ee..9a08aeabb00f3b6 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -23,9 +23,8 @@ import { TriggerToActionsRegistry, TriggerId, TriggerContextMapping, - ActionType, } from '../types'; -import { Action, ActionByType } from '../actions'; +import { ActionInternal, Action, ActionDefinition, ActionContext } from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -76,49 +75,41 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = (action: ActionByType) => { - if (this.actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); + public readonly registerAction = ( + definition: A + ): Action> => { + if (this.actions.has(definition.id)) { + throw new Error(`Action [action.id = ${definition.id}] already registered.`); } + const action = new ActionInternal(definition); + this.actions.set(action.id, action); + + return action; }; - public readonly getAction = (id: string): ActionByType => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); + public readonly unregisterAction = (actionId: string): void => { + if (!this.actions.has(actionId)) { + throw new Error(`Action [action.id = ${actionId}] is not registered.`); } - return this.actions.get(id) as ActionByType; + this.actions.delete(actionId); }; - public readonly attachAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action - ): void => { - if (!this.actions.has(action.id)) { - this.registerAction(action); - } else { - const registeredAction = this.actions.get(action.id); - if (registeredAction !== action) { - throw new Error(`A different action instance with this id is already registered.`); - } - } - + public readonly attachAction = (triggerId: T, actionId: string): void => { const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === action.id)) { - this.triggerToActions.set(triggerId, [...actionIds!, action.id]); + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); } }; @@ -139,6 +130,32 @@ export class UiActionsService { ); }; + /** + * `addTriggerAction` is similar to `attachAction` as it attaches action to a + * trigger, but it also registers the action, if it has not been registered, yet. + * + * `addTriggerAction` also infers better typing of the `action` argument. + */ + public readonly addTriggerAction = ( + triggerId: T, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: Action + ): void => { + if (!this.actions.has(action.id)) this.registerAction(action); + this.attachAction(triggerId, action.id); + }; + + public readonly getAction = ( + id: string + ): Action> => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionInternal; + }; + public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -147,9 +164,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< - Action - >; + const actions = actionIds! + .map(actionId => this.actions.get(actionId) as ActionInternal) + .filter(Boolean); return actions as Array>>; }; diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 5b427f918c1732a..ade21ee4b7d9135 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action1); - setup.attachAction(trigger.id, action2); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index f5a6a96fb41a403..55ccac42ff255ab 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Action } from '../actions'; +import { ActionInternal, Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,13 +47,14 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.attachAction('trigger' as TriggerId, action1); + setup.addTriggerAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - setup.attachAction('trigger' as TriggerId, action2); + setup.addTriggerAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index c5e68e5d5ca5a6c..21dd17ed82e3fcb 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.attachAction('trigger' as TriggerId, action); + uiActions.setup.addTriggerAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.attachAction(testTrigger.id, action1); + setup.addTriggerAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 7d63b1b6d5669f4..dfa71cec89595ef 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c638db0ce9dab80..c7c998907381abb 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,8 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: 'Select range', + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 1fc92d7c0cb1b9b..e499c404ae7457f 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -65,8 +65,11 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, + title: this.trigger.title, closeMenu: () => session.close(), }); - const session = openContextMenu([panel]); + const session = openContextMenu([panel], { + 'data-test-subj': 'multipleActionsContextMenu', + }); } } diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index ad32bdc1b564e1f..5fe060f55dc773b 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,8 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: 'Value clicked', + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index e6247a8bafff79f..85c87306cc4f94e 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ActionByType } from './actions/action'; +import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; @@ -25,7 +25,7 @@ import { IEmbeddable } from '../../embeddable/public'; import { RangeSelectTriggerContext, ValueClickTriggerContext } from '../../embeddable/public'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts new file mode 100644 index 000000000000000..a6943e54f016cba --- /dev/null +++ b/src/plugins/ui_actions/public/util/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './presentable'; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts new file mode 100644 index 000000000000000..f43b776e7465893 --- /dev/null +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from 'src/plugins/kibana_utils/public'; + +/** + * Represents something that can be displayed to user in UI. + */ +export interface Presentable { + /** + * ID that uniquely identifies this object. + */ + readonly id: string; + + /** + * Determines the display order in relation to other items. Higher numbers are + * displayed first. + */ + readonly order: number; + + /** + * `UiComponent` to render when displaying this entity as a context menu item. + * If not provided, `getDisplayName` will be used instead. + */ + readonly MenuItem?: UiComponent<{ context: Context }>; + + /** + * Optional EUI icon type that can be displayed along with the title. + */ + getIconType(context: Context): string | undefined; + + /** + * Returns a title to be displayed to the user. + */ + getDisplayName(context: Context): string; + + /** + * This method should return a link if this item can be clicked on. The link + * is used to navigate user if user middle-clicks it or Ctrl + clicks or + * right-clicks and selects "Open in new tab". + */ + getHref?(context: Context): Promise; + + /** + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. + */ + isCompatible(context: Context): Promise; +} diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/index.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/index.js index db6365f88d0ff23..906730b394ae23c 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/index.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/index.js @@ -29,6 +29,8 @@ import { getTimerange } from './get_timerange'; import { mapBucket } from './map_bucket'; import { parseSettings } from './parse_settings'; +export { overwrite } from './overwrite'; + export const helpers = { bucketTransform, getAggValue, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/overwrite.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/overwrite.js new file mode 100644 index 000000000000000..2eba5155a208dab --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/overwrite.js @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import set from 'set-value'; + +/** + * Set path in obj. Behaves like lodash `set` + * @param obj The object to mutate + * @param path The path of the sub-property to set + * @param val The value to set the sub-property to + */ +export function overwrite(obj, path, val) { + set(obj, path, undefined); + set(obj, path, val); +} diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js index 283f2c115d4f555..f7b5cc9131ac4f5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { getTimerange } from '../../helpers/get_timerange'; import { search } from '../../../../../../../plugins/data/server'; @@ -37,7 +37,7 @@ export function dateHistogram( const { from, to } = getTimerange(req); const timezone = capabilities.searchTimezone; - _.set(doc, `aggs.${annotation.id}.date_histogram`, { + overwrite(doc, `aggs.${annotation.id}.date_histogram`, { field: timeField, min_doc_count: 0, time_zone: timezone, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js index ae1e0bdc3884cc5..4cc3fd094cc1362 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/annotations/top_hits.js @@ -17,13 +17,13 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; export function topHits(req, panel, annotation) { return next => doc => { const fields = (annotation.fields && annotation.fields.split(/[,\s]+/)) || []; const timeField = annotation.time_field; - _.set(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { + overwrite(doc, `aggs.${annotation.id}.aggs.hits.top_hits`, { sort: [ { [timeField]: { order: 'desc' }, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js index df63a14ea5ee454..cc6466145dcdfd7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { offsetTime } from '../../offset_time'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -34,7 +34,7 @@ export function dateHistogram(req, panel, series, esQueryConfig, indexPatternObj const { from, to } = offsetTime(req, series.offset_time); const timezone = capabilities.searchTimezone; - set(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { + overwrite(doc, `aggs.${series.id}.aggs.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, time_zone: timezone, @@ -47,7 +47,7 @@ export function dateHistogram(req, panel, series, esQueryConfig, indexPatternObj }; const getDateHistogramForEntireTimerangeMode = () => - set(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { + overwrite(doc, `aggs.${series.id}.aggs.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); @@ -58,7 +58,7 @@ export function dateHistogram(req, panel, series, esQueryConfig, indexPatternObj // master - set(doc, `aggs.${series.id}.meta`, { + overwrite(doc, `aggs.${series.id}.meta`, { timeField, intervalString, bucketSize, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js index 32a75b1268d06da..0ca562c49b4c779 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/filter_ratios.js @@ -19,16 +19,16 @@ const filter = metric => metric.type === 'filter_ratio'; import { bucketTransform } from '../../helpers/bucket_transform'; -import _ from 'lodash'; +import { overwrite } from '../../helpers'; export function ratios(req, panel, series) { return next => doc => { if (series.metrics.some(filter)) { series.metrics.filter(filter).forEach(metric => { - _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, { + overwrite(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.filter`, { query_string: { query: metric.numerator || '*', analyze_wildcard: true }, }); - _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, { + overwrite(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.filter`, { query_string: { query: metric.denominator || '*', analyze_wildcard: true }, }); @@ -46,8 +46,12 @@ export function ratios(req, panel, series) { metricAgg = {}; } const aggBody = { metric: metricAgg }; - _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.aggs`, aggBody); - _.set( + overwrite( + doc, + `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-numerator.aggs`, + aggBody + ); + overwrite( doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}-denominator.aggs`, aggBody @@ -56,7 +60,7 @@ export function ratios(req, panel, series) { denominatorPath = `${metric.id}-denominator>metric`; } - _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, { + overwrite(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, { bucket_script: { buckets_path: { numerator: numeratorPath, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js index 857f2ab1d04853b..d390821f9ad9888 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/metric_buckets.js @@ -16,8 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -33,7 +32,7 @@ export function metricBuckets(req, panel, series, esQueryConfig, indexPatternObj if (fn) { try { const bucket = fn(metric, series.metrics, intervalString); - _.set(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, bucket); + overwrite(doc, `aggs.${series.id}.aggs.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/normalize_query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/normalize_query.js index 0a701d1de577fa2..f76f3a531a37df0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/normalize_query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/normalize_query.js @@ -16,9 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -const { set, get, isEmpty } = require('lodash'); +import { overwrite } from '../../helpers'; +import _ from 'lodash'; -const isEmptyFilter = (filter = {}) => Boolean(filter.match_all) && isEmpty(filter.match_all); +const isEmptyFilter = (filter = {}) => Boolean(filter.match_all) && _.isEmpty(filter.match_all); const hasSiblingPipelineAggregation = (aggs = {}) => Object.keys(aggs).length > 1; /* For grouping by the 'Everything', the splitByEverything request processor @@ -30,12 +31,12 @@ const hasSiblingPipelineAggregation = (aggs = {}) => Object.keys(aggs).length > * */ function removeEmptyTopLevelAggregation(doc, series) { - const filter = get(doc, `aggs.${series.id}.filter`); + const filter = _.get(doc, `aggs.${series.id}.filter`); if (isEmptyFilter(filter) && !hasSiblingPipelineAggregation(doc.aggs[series.id].aggs)) { - const meta = get(doc, `aggs.${series.id}.meta`); - set(doc, `aggs`, doc.aggs[series.id].aggs); - set(doc, `aggs.timeseries.meta`, meta); + const meta = _.get(doc, `aggs.${series.id}.meta`); + overwrite(doc, `aggs`, doc.aggs[series.id].aggs); + overwrite(doc, `aggs.timeseries.meta`, meta); } return doc; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js index 1ff548cc19e0226..45db28fa98f5e74 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/positive_rate.js @@ -20,7 +20,7 @@ import { getBucketSize } from '../../helpers/get_bucket_size'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; import { bucketTransform } from '../../helpers/bucket_transform'; -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; export const filter = metric => metric.type === 'positive_rate'; @@ -48,9 +48,13 @@ export const createPositiveRate = (doc, intervalString, aggRoot) => metric => { const derivativeBucket = derivativeFn(derivativeMetric, fakeSeriesMetrics, intervalString); const positiveOnlyBucket = positiveOnlyFn(positiveOnlyMetric, fakeSeriesMetrics, intervalString); - set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-max`, maxBucket); - set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-derivative`, derivativeBucket); - set(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, positiveOnlyBucket); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-max`, maxBucket); + overwrite( + doc, + `${aggRoot}.timeseries.aggs.${metric.id}-positive-rate-derivative`, + derivativeBucket + ); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, positiveOnlyBucket); }; export function positiveRate(req, panel, series, esQueryConfig, indexPatternObject, capabilities) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js index bbb7d60c8ef06e6..d677b2564c94099 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/sibling_buckets.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -40,7 +40,7 @@ export function siblingBuckets( if (fn) { try { const bucket = fn(metric, series.metrics, bucketSize); - _.set(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); + overwrite(doc, `aggs.${series.id}.aggs.${metric.id}`, bucket); } catch (e) { // meh } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js index 54424bed0688b31..c567e8ded0e6113 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_everything.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; export function splitByEverything(req, panel, series) { return next => doc => { @@ -25,7 +25,7 @@ export function splitByEverything(req, panel, series) { series.split_mode === 'everything' || (series.split_mode === 'terms' && !series.terms_field) ) { - _.set(doc, `aggs.${series.id}.filter.match_all`, {}); + overwrite(doc, `aggs.${series.id}.filter.match_all`, {}); } return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js index 80b4ef70a3f0858..0822878aa9178f9 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filter.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; export function splitByFilter(req, panel, series, esQueryConfig, indexPattern) { @@ -26,7 +26,7 @@ export function splitByFilter(req, panel, series, esQueryConfig, indexPattern) { return next(doc); } - set( + overwrite( doc, `aggs.${series.id}.filter`, esQuery.buildEsQuery(indexPattern, [series.filter], [], esQueryConfig) diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js index d023c28cdb25ef2..a3d2725ef58b564 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_filters.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; export function splitByFilters(req, panel, series, esQueryConfig, indexPattern) { @@ -26,7 +26,7 @@ export function splitByFilters(req, panel, series, esQueryConfig, indexPattern) series.split_filters.forEach(filter => { const builtEsQuery = esQuery.buildEsQuery(indexPattern, [filter.filter], [], esQueryConfig); - set(doc, `aggs.${series.id}.filters.filters.${filter.id}`, builtEsQuery); + overwrite(doc, `aggs.${series.id}.filters.filters.${filter.id}`, builtEsQuery); }); } return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js index 3ad00272c66cb71..db5a3f50f2e6238 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/split_by_terms.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { basicAggs } from '../../../../../common/basic_aggs'; import { getBucketsPath } from '../../helpers/get_buckets_path'; import { bucketTransform } from '../../helpers/bucket_transform'; @@ -27,13 +27,13 @@ export function splitByTerms(req, panel, series) { if (series.split_mode === 'terms' && series.terms_field) { const direction = series.terms_direction || 'desc'; const metric = series.metrics.find(item => item.id === series.terms_order_by); - set(doc, `aggs.${series.id}.terms.field`, series.terms_field); - set(doc, `aggs.${series.id}.terms.size`, series.terms_size); + overwrite(doc, `aggs.${series.id}.terms.field`, series.terms_field); + overwrite(doc, `aggs.${series.id}.terms.size`, series.terms_size); if (series.terms_include) { - set(doc, `aggs.${series.id}.terms.include`, series.terms_include); + overwrite(doc, `aggs.${series.id}.terms.include`, series.terms_include); } if (series.terms_exclude) { - set(doc, `aggs.${series.id}.terms.exclude`, series.terms_exclude); + overwrite(doc, `aggs.${series.id}.terms.exclude`, series.terms_exclude); } if (metric && metric.type !== 'count' && ~basicAggs.indexOf(metric.type)) { const sortAggKey = `${series.terms_order_by}-SORT`; @@ -42,12 +42,12 @@ export function splitByTerms(req, panel, series) { series.terms_order_by, sortAggKey ); - set(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction }); - set(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) }); + overwrite(doc, `aggs.${series.id}.terms.order`, { [bucketPath]: direction }); + overwrite(doc, `aggs.${series.id}.aggs`, { [sortAggKey]: fn(metric) }); } else if (['_key', '_count'].includes(series.terms_order_by)) { - set(doc, `aggs.${series.id}.terms.order`, { [series.terms_order_by]: direction }); + overwrite(doc, `aggs.${series.id}.terms.order`, { [series.terms_order_by]: direction }); } else { - set(doc, `aggs.${series.id}.terms.order`, { _count: direction }); + overwrite(doc, `aggs.${series.id}.terms.order`, { _count: direction }); } } return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js index 6afa434a5508544..6b51415627fe91a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/date_histogram.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { isLastValueTimerangeMode } from '../../helpers/get_timerange_mode'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -41,7 +41,7 @@ export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, cap panel.series.forEach(column => { const aggRoot = calculateAggRoot(doc, column); - set(doc, `${aggRoot}.timeseries.date_histogram`, { + overwrite(doc, `${aggRoot}.timeseries.date_histogram`, { field: timeField, min_doc_count: 0, time_zone: timezone, @@ -52,7 +52,7 @@ export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, cap ...dateHistogramInterval(intervalString), }); - set(doc, aggRoot.replace(/\.aggs$/, '.meta'), { + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), { timeField, intervalString, bucketSize, @@ -64,12 +64,12 @@ export function dateHistogram(req, panel, esQueryConfig, indexPatternObject, cap panel.series.forEach(column => { const aggRoot = calculateAggRoot(doc, column); - set(doc, `${aggRoot}.timeseries.auto_date_histogram`, { + overwrite(doc, `${aggRoot}.timeseries.auto_date_histogram`, { field: timeField, buckets: 1, }); - set(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); + overwrite(doc, aggRoot.replace(/\.aggs$/, '.meta'), meta); }); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js index a05c414f1a31160..8bce521e742d885 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/filter_ratios.js @@ -19,7 +19,7 @@ const filter = metric => metric.type === 'filter_ratio'; import { bucketTransform } from '../../helpers/bucket_transform'; -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { calculateAggRoot } from './calculate_agg_root'; export function ratios(req, panel) { @@ -28,10 +28,10 @@ export function ratios(req, panel) { const aggRoot = calculateAggRoot(doc, column); if (column.metrics.some(filter)) { column.metrics.filter(filter).forEach(metric => { - _.set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, { + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.filter`, { query_string: { query: metric.numerator || '*', analyze_wildcard: true }, }); - _.set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, { + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}-denominator.filter`, { query_string: { query: metric.denominator || '*', analyze_wildcard: true }, }); @@ -45,13 +45,13 @@ export function ratios(req, panel) { field: metric.field, }), }; - _.set(doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.aggs`, aggBody); - _.set(doc, `${aggBody}.timeseries.aggs.${metric.id}-denominator.aggs`, aggBody); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}-numerator.aggs`, aggBody); + overwrite(doc, `${aggBody}.timeseries.aggs.${metric.id}-denominator.aggs`, aggBody); numeratorPath = `${metric.id}-numerator>metric`; denominatorPath = `${metric.id}-denominator>metric`; } - _.set(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, { + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, { bucket_script: { buckets_path: { numerator: numeratorPath, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js index 44418efe42dbbe2..d38282ed3e9aa4a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/metric_buckets.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -36,7 +36,7 @@ export function metricBuckets(req, panel, esQueryConfig, indexPatternObject) { if (fn) { try { const bucket = fn(metric, column.metrics, intervalString); - _.set(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); + overwrite(doc, `${aggRoot}.timeseries.aggs.${metric.id}`, bucket); } catch (e) { // meh } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/normalize_query.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/normalize_query.js index 2b5014a2535dcaa..c38351e37dc31d4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/normalize_query.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/normalize_query.js @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -const { set, get, isEmpty, forEach } = require('lodash'); - -const isEmptyFilter = (filter = {}) => Boolean(filter.match_all) && isEmpty(filter.match_all); +import _ from 'lodash'; +import { overwrite } from '../../helpers'; +const isEmptyFilter = (filter = {}) => Boolean(filter.match_all) && _.isEmpty(filter.match_all); const hasSiblingPipelineAggregation = (aggs = {}) => Object.keys(aggs).length > 1; /* Last query handler in the chain. You can use this handler @@ -29,26 +29,26 @@ const hasSiblingPipelineAggregation = (aggs = {}) => Object.keys(aggs).length > */ export function normalizeQuery() { return () => doc => { - const series = get(doc, 'aggs.pivot.aggs'); + const series = _.get(doc, 'aggs.pivot.aggs'); const normalizedSeries = {}; - forEach(series, (value, seriesId) => { - const filter = get(value, `filter`); + _.forEach(series, (value, seriesId) => { + const filter = _.get(value, `filter`); if (isEmptyFilter(filter) && !hasSiblingPipelineAggregation(value.aggs)) { - const agg = get(value, 'aggs.timeseries'); + const agg = _.get(value, 'aggs.timeseries'); const meta = { - ...get(value, 'meta'), + ..._.get(value, 'meta'), seriesId, }; - set(normalizedSeries, `${seriesId}`, agg); - set(normalizedSeries, `${seriesId}.meta`, meta); + overwrite(normalizedSeries, `${seriesId}`, agg); + overwrite(normalizedSeries, `${seriesId}.meta`, meta); } else { - set(normalizedSeries, `${seriesId}`, value); + overwrite(normalizedSeries, `${seriesId}`, value); } }); - set(doc, 'aggs.pivot.aggs', normalizedSeries); + overwrite(doc, 'aggs.pivot.aggs', normalizedSeries); return doc; }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/pivot.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/pivot.js index 972a8c71ed515e2..6597973c28cf00a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/pivot.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/pivot.js @@ -17,7 +17,8 @@ * under the License. */ -import { get, set, last } from 'lodash'; +import { get, last } from 'lodash'; +import { overwrite } from '../../helpers'; import { basicAggs } from '../../../../../common/basic_aggs'; import { getBucketsPath } from '../../helpers/get_buckets_path'; @@ -27,13 +28,13 @@ export function pivot(req, panel) { return next => doc => { const { sort } = req.payload.state; if (panel.pivot_id) { - set(doc, 'aggs.pivot.terms.field', panel.pivot_id); - set(doc, 'aggs.pivot.terms.size', panel.pivot_rows); + overwrite(doc, 'aggs.pivot.terms.field', panel.pivot_id); + overwrite(doc, 'aggs.pivot.terms.size', panel.pivot_rows); if (sort) { const series = panel.series.find(item => item.id === sort.column); const metric = series && last(series.metrics); if (metric && metric.type === 'count') { - set(doc, 'aggs.pivot.terms.order', { _count: sort.order }); + overwrite(doc, 'aggs.pivot.terms.order', { _count: sort.order }); } else if (metric && basicAggs.includes(metric.type)) { const sortAggKey = `${metric.id}-SORT`; const fn = bucketTransform[metric.type]; @@ -41,16 +42,16 @@ export function pivot(req, panel) { metric.id, sortAggKey ); - set(doc, `aggs.pivot.terms.order`, { [bucketPath]: sort.order }); - set(doc, `aggs.pivot.aggs`, { [sortAggKey]: fn(metric) }); + overwrite(doc, `aggs.pivot.terms.order`, { [bucketPath]: sort.order }); + overwrite(doc, `aggs.pivot.aggs`, { [sortAggKey]: fn(metric) }); } else { - set(doc, 'aggs.pivot.terms.order', { + overwrite(doc, 'aggs.pivot.terms.order', { _key: get(sort, 'order', 'asc'), }); } } } else { - set(doc, 'aggs.pivot.filter.match_all', {}); + overwrite(doc, 'aggs.pivot.filter.match_all', {}); } return next(doc); }; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js index 758da28e932328f..b7ffbaa65619cd2 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/sibling_buckets.js @@ -17,7 +17,7 @@ * under the License. */ -import _ from 'lodash'; +import { overwrite } from '../../helpers'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { bucketTransform } from '../../helpers/bucket_transform'; import { getIntervalAndTimefield } from '../../get_interval_and_timefield'; @@ -36,7 +36,7 @@ export function siblingBuckets(req, panel, esQueryConfig, indexPatternObject) { if (fn) { try { const bucket = fn(metric, column.metrics, bucketSize); - _.set(doc, `${aggRoot}.${metric.id}`, bucket); + overwrite(doc, `${aggRoot}.${metric.id}`, bucket); } catch (e) { // meh } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js index 35036abed320f9a..fd03921346fb8b3 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_everything.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; export function splitByEverything(req, panel, esQueryConfig, indexPattern) { @@ -26,13 +26,13 @@ export function splitByEverything(req, panel, esQueryConfig, indexPattern) { .filter(c => !(c.aggregate_by && c.aggregate_function)) .forEach(column => { if (column.filter) { - set( + overwrite( doc, `aggs.pivot.aggs.${column.id}.filter`, esQuery.buildEsQuery(indexPattern, [column.filter], [], esQueryConfig) ); } else { - set(doc, `aggs.pivot.aggs.${column.id}.filter.match_all`, {}); + overwrite(doc, `aggs.pivot.aggs.${column.id}.filter.match_all`, {}); } }); return next(doc); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js index 5b7ae735cd50f5a..a34d53a6bc975d7 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/table/split_by_terms.js @@ -17,7 +17,7 @@ * under the License. */ -import { set } from 'lodash'; +import { overwrite } from '../../helpers'; import { esQuery } from '../../../../../../data/server'; export function splitByTerms(req, panel, esQueryConfig, indexPattern) { @@ -25,11 +25,11 @@ export function splitByTerms(req, panel, esQueryConfig, indexPattern) { panel.series .filter(c => c.aggregate_by && c.aggregate_function) .forEach(column => { - set(doc, `aggs.pivot.aggs.${column.id}.terms.field`, column.aggregate_by); - set(doc, `aggs.pivot.aggs.${column.id}.terms.size`, 100); + overwrite(doc, `aggs.pivot.aggs.${column.id}.terms.field`, column.aggregate_by); + overwrite(doc, `aggs.pivot.aggs.${column.id}.terms.size`, 100); if (column.filter) { - set( + overwrite( doc, `aggs.pivot.aggs.${column.id}.column_filter.filter`, esQuery.buildEsQuery(indexPattern, [column.filter], [], esQueryConfig) diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 1c545bb36cff032..71b31b7f74168f3 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -265,6 +265,7 @@ export class VisualizeEmbeddable extends Embeddable { + const resp = await supertest.get('/api/status').expect(200); + buildNum = resp.body.version.build_number; + }); + + it('returns gzip files when client only supports gzip', () => + supertest + // We use the kbn-ui-shared-deps for these tests since they are always built with br compressed outputs, + // even in dev. Bundles built by @kbn/optimizer are only built with br compression in dist mode. + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns br files when client only supports br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns br files when client only supports gzip and br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns gzip files when client prefers gzip', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip;q=1.0, br;q=0.5') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns gzip files when no brotli version exists', () => + supertest + .get(`/${buildNum}/bundles/commons.style.css`) // legacy optimizer does not create brotli outputs + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'gzip')); + }); +} diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 8f2012d7f184d06..05544029f62d796 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -242,7 +242,9 @@ export default function({ getService, getPageObjects }) { await inspector.close(); }); - it('does not scale top hit agg', async () => { + // Preventing ES Promotion for master (8.0) + // https://github.com/elastic/kibana/issues/64734 + it.skip('does not scale top hit agg', async () => { const expectedTableData = [ ['2015-09-20 00:00', '6', '9.035KB'], ['2015-09-20 01:00', '9', '5.854KB'], diff --git a/test/functional/config.js b/test/functional/config.js index 0fbde95afe12c74..8cc0a34e352a922 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -25,11 +25,12 @@ export default async function({ readConfigFile }) { return { testFiles: [ + require.resolve('./apps/bundles'), require.resolve('./apps/console'), - require.resolve('./apps/getting_started'), require.resolve('./apps/context'), require.resolve('./apps/dashboard'), require.resolve('./apps/discover'), + require.resolve('./apps/getting_started'), require.resolve('./apps/home'), require.resolve('./apps/management'), require.resolve('./apps/saved_objects_management'), diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index b76ce141a44184e..36a7674c47ab0c2 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -104,16 +104,21 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide public async getDashboardIdFromCurrentUrl() { const currentUrl = await browser.getCurrentUrl(); - const urlSubstring = 'kibana#/dashboard/'; - const startOfIdIndex = currentUrl.indexOf(urlSubstring) + urlSubstring.length; - const endIndex = currentUrl.indexOf('?'); - const id = currentUrl.substring(startOfIdIndex, endIndex < 0 ? currentUrl.length : endIndex); + const id = this.getDashboardIdFromUrl(currentUrl); log.debug(`Dashboard id extracted from ${currentUrl} is ${id}`); return id; } + public getDashboardIdFromUrl(url: string) { + const urlSubstring = 'kibana#/dashboard/'; + const startOfIdIndex = url.indexOf(urlSubstring) + urlSubstring.length; + const endIndex = url.indexOf('?'); + const id = url.substring(startOfIdIndex, endIndex < 0 ? url.length : endIndex); + return id; + } + /** * Returns true if already on the dashboard landing page (that page doesn't have a link to itself). * @returns {Promise} @@ -512,6 +517,20 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide return checkList.filter(viz => viz.isPresent === false).map(viz => viz.name); } + + public async getPanelDrilldownCount(panelIndex = 0): Promise { + log.debug('getPanelDrilldownCount'); + const panel = (await this.getDashboardPanels())[panelIndex]; + try { + const count = await panel.findByTestSubject( + 'embeddablePanelNotification-ACTION_PANEL_NOTIFICATIONS' + ); + return Number.parseInt(await count.getVisibleText(), 10); + } catch (e) { + // if not found then this is 0 (we don't show badge with 0) + return 0; + } + } } return new DashboardPage(); diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a10bb013b3af482..02ed9e9865d9a3a 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -51,6 +51,7 @@ import { ToastsProvider } from './toasts'; import { PieChartProvider } from './visualizations'; import { ListingTableProvider } from './listing_table'; import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; +import { KibanaSupertestProvider } from './supertest'; export const services = { ...commonServiceProviders, @@ -83,4 +84,5 @@ export const services = { toasts: ToastsProvider, savedQueryManagementComponent: SavedQueryManagementComponentProvider, elasticChart: ElasticChartProvider, + supertest: KibanaSupertestProvider, }; diff --git a/test/functional/services/supertest.ts b/test/functional/services/supertest.ts new file mode 100644 index 000000000000000..30c7db87a8f8bfa --- /dev/null +++ b/test/functional/services/supertest.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import { format as formatUrl } from 'url'; + +import supertestAsPromised from 'supertest-as-promised'; + +export function KibanaSupertestProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + return supertestAsPromised(kibanaServerUrl); +} diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts b/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts index 8ea8d2ff49e3beb..9ae1021227315ff 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/public/plugin.ts @@ -27,14 +27,10 @@ export class SampelPanelActionTestPlugin implements Plugin { public setup(core: CoreSetup, { uiActions }: { uiActions: UiActionsSetup }) { const samplePanelAction = createSamplePanelAction(core.getStartServices); - - uiActions.registerAction(samplePanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelAction); - const samplePanelLink = createSamplePanelLink(); - uiActions.registerAction(samplePanelLink); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, samplePanelLink); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, samplePanelLink); return {}; } diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index e5f5faa6ac361df..b47e84216dd16e2 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -69,11 +69,10 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); - plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/typings/accept.d.ts b/typings/accept.d.ts new file mode 100644 index 000000000000000..69cadc7491eeb46 --- /dev/null +++ b/typings/accept.d.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +declare module 'accept' { + // @types/accept does not include the `preferences` argument so we override the type to include it + export function encodings(encodingHeader?: string, preferences?: string[]): string[]; +} diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 9f43bf8da06010c..ccf8739dd973001 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -3,11 +3,13 @@ "paths": { "xpack.actions": "plugins/actions", "xpack.advancedUiActions": "plugins/advanced_ui_actions", + "xpack.uiActionsEnhanced": "examples/ui_actions_enhanced_examples", "xpack.alerting": "plugins/alerting", "xpack.alertingBuiltins": "plugins/alerting_builtins", "xpack.apm": ["legacy/plugins/apm", "plugins/apm"], "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", + "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.crossClusterReplication": "plugins/cross_cluster_replication", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", diff --git a/x-pack/examples/ui_actions_enhanced_examples/README.md b/x-pack/examples/ui_actions_enhanced_examples/README.md index c9f53137d868758..ec049bbd33dec43 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/README.md +++ b/x-pack/examples/ui_actions_enhanced_examples/README.md @@ -1,3 +1,36 @@ -## Ui actions enhanced examples +# Ui actions enhanced examples -To run this example, use the command `yarn start --run-examples`. +To run this example plugin, use the command `yarn start --run-examples`. + + +## Drilldown examples + +This plugin holds few examples on how to add drilldown types to dashboard. + +To play with drilldowns, open any dashboard, click "Edit" to put it in *edit mode*. +Now when opening context menu of dashboard panels you should see "Create drilldown" option. + +![image](https://user-images.githubusercontent.com/9773803/80460907-c2ef7880-8934-11ea-8400-533bb9d57e36.png) + +Once you click "Create drilldown" you should be able to see drilldowns added by +this sample plugin. + +![image](https://user-images.githubusercontent.com/9773803/80460408-131a0b00-8934-11ea-81e4-137e9e33f34b.png) + + +### `dashboard_hello_world_drilldown` + +`dashboard_hello_world_drilldown` is the most basic "hello world" example showing +how a drilldown can be built, all in one file. + +### `dashboard_to_url_drilldown` + +`dashboard_to_url_drilldown` is a good starting point for build a drilldown +that navigates somewhere externally. + +One can see how middle-click or Ctrl + click behavior could be supported using +`getHref` field. + +### `dashboard_to_discover_drilldown` + +`dashboard_to_discover_drilldown` shows how a real-world drilldown could look like. diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index f75852edced5c07..e220cdd5cd297bf 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,6 +5,6 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, - "requiredPlugins": ["uiActions", "data"], + "requiredPlugins": ["advancedUiActions", "data"], "optionalPlugins": [] } diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md new file mode 100644 index 000000000000000..47a3429b16d7a5d --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/README.md @@ -0,0 +1 @@ +This folder contains a one-file example of the most basic drilldown implementation. diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx new file mode 100644 index 000000000000000..b1e1040daee6eaf --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public'; +import { + RangeSelectTriggerContext, + ValueClickTriggerContext, +} from '../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; + +export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; + +export interface Config { + name: string; +} + +const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN'; + +export class DashboardHelloWorldDrilldown implements Drilldown { + public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN; + + public readonly order = 6; + + public readonly getDisplayName = () => 'Say hello drilldown'; + + public readonly euiIcon = 'cheer'; + + private readonly ReactCollectConfig: React.FC> = ({ + config, + onConfig, + }) => ( + + onConfig({ ...config, name: event.target.value })} + /> + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + name: '', + }); + + public readonly isConfigValid = (config: Config): config is Config => { + return !!config.name; + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + alert(`Hello, ${config.name}`); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx new file mode 100644 index 000000000000000..69cf260a20a81db --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/collect_config_container.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { CollectConfigProps } from './types'; +import { DiscoverDrilldownConfig, IndexPatternItem } from './components/discover_drilldown_config'; +import { Params } from './drilldown'; + +export interface CollectConfigContainerProps extends CollectConfigProps { + params: Params; +} + +export const CollectConfigContainer: React.FC = ({ + config, + onConfig, + params: { start }, +}) => { + const isMounted = useMountedState(); + const [indexPatterns, setIndexPatterns] = useState([]); + + useEffect(() => { + (async () => { + const indexPatternSavedObjects = await start().plugins.data.indexPatterns.getCache(); + if (!isMounted()) return; + setIndexPatterns( + indexPatternSavedObjects + ? indexPatternSavedObjects.map(indexPattern => ({ + id: indexPattern.id, + title: indexPattern.attributes.title, + })) + : [] + ); + })(); + }, [isMounted, start]); + + return ( + { + onConfig({ ...config, indexPatternId }); + }} + customIndexPattern={config.customIndexPattern} + onCustomIndexPatternToggle={() => + onConfig({ + ...config, + customIndexPattern: !config.customIndexPattern, + indexPatternId: undefined, + }) + } + carryFiltersAndQuery={config.carryFiltersAndQuery} + onCarryFiltersAndQueryToggle={() => + onConfig({ + ...config, + carryFiltersAndQuery: !config.carryFiltersAndQuery, + }) + } + carryTimeRange={config.carryTimeRange} + onCarryTimeRangeToggle={() => + onConfig({ + ...config, + carryTimeRange: !config.carryTimeRange, + }) + } + /> + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx new file mode 100644 index 000000000000000..cf379b29a003907 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/discover_drilldown_config.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSelect, EuiSwitch, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { txtChooseDestinationIndexPattern } from './i18n'; + +export interface IndexPatternItem { + id: string; + title: string; +} + +export interface DiscoverDrilldownConfigProps { + activeIndexPatternId?: string; + indexPatterns: IndexPatternItem[]; + onIndexPatternSelect: (indexPatternId: string) => void; + customIndexPattern?: boolean; + onCustomIndexPatternToggle?: () => void; + carryFiltersAndQuery?: boolean; + onCarryFiltersAndQueryToggle?: () => void; + carryTimeRange?: boolean; + onCarryTimeRangeToggle?: () => void; +} + +export const DiscoverDrilldownConfig: React.FC = ({ + activeIndexPatternId, + indexPatterns, + onIndexPatternSelect, + customIndexPattern, + onCustomIndexPatternToggle, + carryFiltersAndQuery, + onCarryFiltersAndQueryToggle, + carryTimeRange, + onCarryTimeRangeToggle, +}) => { + return ( + <> + +

+ This is an example drilldown. It is meant as a starting point for developers, so they can + grab this code and get started. It does not provide a complete working functionality but + serves as a getting started example. +

+

+ Implementation of the actual Go to Discover drilldown is tracked in{' '} + #60227 +

+ + + {!!onCustomIndexPatternToggle && ( + <> + + + + {!!customIndexPattern && ( + + ({ value: id, text: title })), + ]} + value={activeIndexPatternId || ''} + onChange={e => onIndexPatternSelect(e.target.value)} + /> + + )} + + + )} + + {!!onCarryFiltersAndQueryToggle && ( + + + + )} + {!!onCarryTimeRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts new file mode 100644 index 000000000000000..ccd75e7dcc3e3db --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChooseDestinationIndexPattern = i18n.translate( + 'xpack.uiActionsEnhanced.components.DiscoverDrilldownConfig.chooseIndexPattern', + { + defaultMessage: 'Choose destination index pattern', + } +); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts new file mode 100644 index 000000000000000..b975a73e55621ff --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/discover_drilldown_config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './discover_drilldown_config'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts new file mode 100644 index 000000000000000..b975a73e55621ff --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './discover_drilldown_config'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts new file mode 100644 index 000000000000000..518642866c2b585 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx new file mode 100644 index 000000000000000..1213ec2f35995e3 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { StartDependencies as Start } from '../plugin'; +import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; +import { StartServicesGetter } from '../../../../../src/plugins/kibana_utils/public'; +import { ActionContext, Config, CollectConfigProps } from './types'; +import { CollectConfigContainer } from './collect_config_container'; +import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public'; +import { txtGoToDiscover } from './i18n'; + +const isOutputWithIndexPatterns = ( + output: unknown +): output is { indexPatterns: Array<{ id: string }> } => { + if (!output || typeof output !== 'object') return false; + return Array.isArray((output as any).indexPatterns); +}; + +export interface Params { + start: StartServicesGetter>; +} + +export class DashboardToDiscoverDrilldown implements Drilldown { + constructor(protected readonly params: Params) {} + + public readonly id = SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN; + + public readonly order = 10; + + public readonly getDisplayName = () => txtGoToDiscover; + + public readonly euiIcon = 'discoverApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + customIndexPattern: false, + carryFiltersAndQuery: true, + carryTimeRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (config.customIndexPattern && !config.indexPatternId) return false; + return true; + }; + + private readonly getPath = async (config: Config, context: ActionContext): Promise => { + let indexPatternId = + !!config.customIndexPattern && !!config.indexPatternId ? config.indexPatternId : ''; + + if (!indexPatternId && !!context.embeddable) { + const output = context.embeddable!.getOutput(); + if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) { + indexPatternId = output.indexPatterns[0].id; + } + } + + const index = indexPatternId ? `,index:'${indexPatternId}'` : ''; + return `#/discover?_g=(filters:!(),refreshInterval:(pause:!f,value:900000),time:(from:now-7d,to:now))&_a=(columns:!(_source),filters:!()${index},interval:auto,query:(language:kuery,query:''),sort:!())`; + }; + + public readonly getHref = async (config: Config, context: ActionContext): Promise => { + return `kibana${await this.getPath(config, context)}`; + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + const path = await this.getPath(config, context); + + await this.params.start().core.application.navigateToApp('kibana', { + path, + }); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts new file mode 100644 index 000000000000000..3e92a9f3f1fe4bf --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/i18n.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtGoToDiscover = i18n.translate('xpack.uiActionsEnhanced.drilldown.goToDiscover', { + defaultMessage: 'Go to Discover (example)', +}); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts new file mode 100644 index 000000000000000..e824c49a6f1fa5a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants'; +export { + DashboardToDiscoverDrilldown, + Params as DashboardToDiscoverDrilldownParams, +} from './drilldown'; +export { + ActionContext as DashboardToDiscoverActionContext, + Config as DashboardToDiscoverConfig, +} from './types'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts new file mode 100644 index 000000000000000..5dfc250a56d288a --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + RangeSelectTriggerContext, + ValueClickTriggerContext, +} from '../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; + +export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; + +export interface Config { + /** + * Whether to use a user selected index pattern, stored in `indexPatternId` field. + */ + customIndexPattern: boolean; + + /** + * ID of index pattern picked by user in UI. If not set, drilldown will use + * the index pattern of the visualization. + */ + indexPatternId?: string; + + /** + * Whether to carry over source dashboard filters and query. + */ + carryFiltersAndQuery: boolean; + + /** + * Whether to carry over source dashboard time range. + */ + carryTimeRange: boolean; +} + +export type CollectConfigProps = CollectConfigPropsBase; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx new file mode 100644 index 000000000000000..cc38386b26385e9 --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/advanced_ui_actions/public'; +import { + RangeSelectTriggerContext, + ValueClickTriggerContext, +} from '../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; + +function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; + +export interface Config { + url: string; + openInNewTab: boolean; +} + +export type CollectConfigProps = CollectConfigPropsBase; + +const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN'; + +export class DashboardToUrlDrilldown implements Drilldown { + public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN; + + public readonly order = 8; + + public readonly getDisplayName = () => 'Go to URL (example)'; + + public readonly euiIcon = 'link'; + + private readonly ReactCollectConfig: React.FC = ({ config, onConfig }) => ( + <> + +

+ This is an example drilldown. It is meant as a starting point for developers, so they can + grab this code and get started. It does not provide a complete working functionality but + serves as a getting started example. +

+

+ Implementation of the actual Go to URL drilldown is tracked in{' '} + #55324 +

+
+ + + onConfig({ ...config, url: event.target.value })} + onBlur={() => { + if (!config.url) return; + if (/https?:\/\//.test(config.url)) return; + onConfig({ ...config, url: 'https://' + config.url }); + }} + /> + + + onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + url: '', + openInNewTab: false, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.url) return false; + return isValidUrl(config.url); + }; + + /** + * `getHref` is need to support mouse middle-click and Cmd + Click behavior + * to open a link in new tab. + */ + public readonly getHref = async (config: Config, context: ActionContext) => { + return config.url; + }; + + public readonly execute = async (config: Config, context: ActionContext) => { + const url = await this.getHref(config, context); + + if (config.openInNewTab) { + window.open(url, '_blank'); + } else { + window.location.href = url; + } + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index a4c43753c82479f..0d4f274caf57ff3 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -5,24 +5,37 @@ */ import { Plugin, CoreSetup, CoreStart } from '../../../../src/core/public'; -import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + AdvancedUiActionsSetup, + AdvancedUiActionsStart, +} from '../../../../x-pack/plugins/advanced_ui_actions/public'; +import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown'; +import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown'; +import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; +import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; export interface SetupDependencies { data: DataPublicPluginSetup; - uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } export interface StartDependencies { data: DataPublicPluginStart; - uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } export class UiActionsEnhancedExamplesPlugin implements Plugin { - public setup(core: CoreSetup, plugins: SetupDependencies) { - // eslint-disable-next-line - console.log('ui_actions_enhanced_examples'); + public setup( + core: CoreSetup, + { advancedUiActions: uiActions }: SetupDependencies + ) { + const start = createStartServicesGetter(core.getStartServices); + + uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); + uiActions.registerDrilldown(new DashboardToUrlDrilldown()); + uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); } public start(core: CoreStart, plugins: StartDependencies) {} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js index f03bc54757c3c7b..2b9bdb59afbdfde 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js @@ -18,7 +18,7 @@ describe('exactly', () => { it("adds an exactly object to 'and'", () => { const result = fn(emptyFilter, { column: 'name', value: 'product2' }); - expect(result.and[0]).toHaveProperty('type', 'exactly'); + expect(result.and[0]).toHaveProperty('filterType', 'exactly'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts index 88a24186d604494..5031e8029957bf0 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -13,7 +13,12 @@ interface Arguments { filterGroup: string; } -export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Arguments, Filter> { +export function exactly(): ExpressionFunctionDefinition< + 'exactly', + ExpressionValueFilter, + Arguments, + ExpressionValueFilter +> { const { help, args: argHelp } = getFunctionHelp().exactly; return { @@ -43,8 +48,9 @@ export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Argum fn: (input, args) => { const { value, column } = args; - const filter = { - type: 'exactly', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'exactly', value, column, and: [], diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts index 6b197148e637367..882d1e2ea58b9a7 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedLens } from './saved_lens'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts index 2985a68cf855cc5..8fc55ddf9cc599a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -8,7 +8,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { TimeRange, Filter as DataFilter } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/plugins/embeddable/public'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -37,7 +37,7 @@ type Return = EmbeddableExpression; export function savedLens(): ExpressionFunctionDefinition< 'savedLens', - Filter | null, + ExpressionValueFilter | null, Arguments, Return > { @@ -63,8 +63,8 @@ export function savedLens(): ExpressionFunctionDefinition< }, }, type: EmbeddableExpressionType, - fn: (context, args) => { - const filters = context ? context.and : []; + fn: (input, args) => { + const filters = input ? input.and : []; return { type: EmbeddableExpressionType, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 63dbae55790a3e6..74e41a030de35d1 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index cba19ce7da80f60..df316d0dd182f97 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -32,7 +32,7 @@ type Output = EmbeddableExpression; export function savedMap(): ExpressionFunctionDefinition< 'savedMap', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts index 67356dae5b3e330..9bd32202b563a0e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedSearch } from './saved_search'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts index 87dc7eb5e814cfd..277d035ed09587b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts @@ -13,7 +13,7 @@ import { } from '../../expression_types'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -24,7 +24,7 @@ type Output = EmbeddableExpression & { id: SearchInput['id' export function savedSearch(): ExpressionFunctionDefinition< 'savedSearch', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 754a113b8755446..8327c1433b9af4d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index d98fea2ec1be892..94c7a1c8a9eeabc 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -12,7 +12,7 @@ import { EmbeddableExpression, } from '../../expression_types'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -31,7 +31,7 @@ const defaultTimeRange = { export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js index aeab0d50c31a71e..834b9d195856ccd 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js @@ -44,7 +44,7 @@ describe('timefilter', () => { from: fromDate, to: toDate, }).and[0] - ).toHaveProperty('type', 'time'); + ).toHaveProperty('filterType', 'time'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts index 249faf6141b46c8..ff7b56d8194dfb9 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts @@ -5,7 +5,7 @@ */ import dateMath from '@elastic/datemath'; -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; interface Arguments { @@ -17,9 +17,9 @@ interface Arguments { export function timefilter(): ExpressionFunctionDefinition< 'timefilter', - Filter, + ExpressionValueFilter, Arguments, - Filter + ExpressionValueFilter > { const { help, args: argHelp } = getFunctionHelp().timefilter; const errors = getFunctionErrors().timefilter; @@ -58,8 +58,9 @@ export function timefilter(): ExpressionFunctionDefinition< } const { from, to, column } = args; - const filter: Filter = { - type: 'time', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'time', column, and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts index 94b2d5228665bbf..2b517664793a7f7 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts @@ -5,12 +5,11 @@ */ import { demodata } from './demodata'; +import { ExpressionValueFilter } from '../../../types'; -const nullFilter = { +const nullFilter: ExpressionValueFilter = { type: 'filter', - meta: {}, - size: null, - sort: [], + filterType: 'filter', and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts index 5cebae5bb669f75..843e2bda47e125a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts @@ -10,14 +10,19 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; import { queryDatatable } from '../../../../common/lib/datatable/query'; import { DemoRows } from './demo_rows_types'; import { getDemoRows } from './get_demo_rows'; -import { Filter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; +import { ExpressionValueFilter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; import { getFunctionHelp } from '../../../../i18n'; interface Arguments { type: string; } -export function demodata(): ExpressionFunctionDefinition<'demodata', Filter, Arguments, Datatable> { +export function demodata(): ExpressionFunctionDefinition< + 'demodata', + ExpressionValueFilter, + Arguments, + Datatable +> { const { help, args: argHelp } = getFunctionHelp().demodata; return { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts index ffb8bb4f3e2a79a..3f5d0610b4c724a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ExpressionFunctionDefinition, Filter } from 'src/plugins/expressions/common'; +import { + ExpressionFunctionDefinition, + ExpressionValueFilter, +} from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { buildESRequest } from '../../../server/lib/build_es_request'; import { getFunctionHelp } from '../../../i18n'; @@ -14,7 +17,12 @@ interface Arguments { query: string; } -export function escount(): ExpressionFunctionDefinition<'escount', Filter, Arguments, any> { +export function escount(): ExpressionFunctionDefinition< + 'escount', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().escount; return { @@ -40,7 +48,8 @@ export function escount(): ExpressionFunctionDefinition<'escount', Filter, Argum fn: (input, args, handlers) => { input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 5bff06bb3933baf..d60297ee2da3f15 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -8,7 +8,7 @@ import squel from 'squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -20,7 +20,12 @@ interface Arguments { count: number; } -export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Arguments, any> { +export function esdocs(): ExpressionFunctionDefinition< + 'esdocs', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().esdocs; return { @@ -67,7 +72,8 @@ export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Argumen input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts index cdb6b5af820155f..b972f5a3bd4a6cb 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts @@ -7,7 +7,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -16,7 +16,12 @@ interface Arguments { timezone: string; } -export function essql(): ExpressionFunctionDefinition<'essql', Filter, Arguments, any> { +export function essql(): ExpressionFunctionDefinition< + 'essql', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().essql; return { diff --git a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js index f61e2b643469796..63945ce7690f9ae 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js +++ b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js @@ -15,14 +15,14 @@ export function queryDatatable(datatable, query) { if (query.and) { query.and.forEach(filter => { // handle exact matches - if (filter.type === 'exactly') { + if (filter.filterType === 'exactly') { datatable.rows = datatable.rows.filter(row => { return row[filter.column] === filter.value; }); } // handle time filters - if (filter.type === 'time') { + if (filter.filterType === 'time') { const columnNames = datatable.columns.map(col => col.name); // remove row if no column match diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx index f746a24e9b261d0..f71123cd28b9088 100644 --- a/x-pack/legacy/plugins/canvas/public/application.tsx +++ b/x-pack/legacy/plugins/canvas/public/application.tsx @@ -130,7 +130,7 @@ export const initializeCanvas = async ( restoreAction = action; startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, action.id); - startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, emptyAction); + startPlugins.uiActions.addTriggerAction(VALUE_CLICK_TRIGGER, emptyAction); } if (setupPlugins.usageCollection) { @@ -147,7 +147,7 @@ export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDe startPlugins.uiActions.detachAction(VALUE_CLICK_TRIGGER, emptyAction.id); if (restoreAction) { - startPlugins.uiActions.attachAction(VALUE_CLICK_TRIGGER, restoreAction); + startPlugins.uiActions.addTriggerAction(VALUE_CLICK_TRIGGER, restoreAction); restoreAction = undefined; } diff --git a/x-pack/legacy/plugins/canvas/public/functions/filters.ts b/x-pack/legacy/plugins/canvas/public/functions/filters.ts index 2a3bc481d7dae37..16d0bb0fff7084b 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/filters.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/filters.ts @@ -11,7 +11,7 @@ import { interpretAst } from '../lib/run_interpreter'; // @ts-ignore untyped local import { getState } from '../state/store'; import { getGlobalFilters } from '../state/selectors/workpad'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; @@ -41,7 +41,12 @@ function getFiltersByGroup(allFilters: string[], groups?: string[], ungrouped = }); } -type FiltersFunction = ExpressionFunctionDefinition<'filters', null, Arguments, Filter>; +type FiltersFunction = ExpressionFunctionDefinition< + 'filters', + null, + Arguments, + ExpressionValueFilter +>; export function filtersFunctionFactory(initialize: InitializeArguments): () => FiltersFunction { return function filters(): FiltersFunction { diff --git a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts index e59d798108945ab..d07b3bf6d1d1c00 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts @@ -11,7 +11,7 @@ import { ExpressionFunctionDefinition, DatatableRow } from 'src/plugins/expressi import { fetch } from '../../common/lib/fetch'; // @ts-ignore untyped local import { buildBoolArray } from '../../server/lib/build_bool_array'; -import { Datatable, Filter } from '../../types'; +import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; @@ -49,7 +49,7 @@ function parseDateMath( type TimelionFunction = ExpressionFunctionDefinition< 'timelion', - Filter, + ExpressionValueFilter, Arguments, Promise >; @@ -94,11 +94,10 @@ export function timelionFunctionFactory(initialize: InitializeArguments): () => fn: (input, args): Promise => { // Timelion requires a time range. Use the time range from the timefilter element in the // workpad, if it exists. Otherwise fall back on the function args. - const timeFilter = input.and.find(and => and.type === 'time'); + const timeFilter = input.and.find(and => and.filterType === 'time'); const range = timeFilter ? { min: timeFilter.from, max: timeFilter.to } : parseDateMath({ from: args.from, to: args.to }, args.timezone, initialize.timefilter); - const body = { extended: { es: { diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts index b422a9451293ff2..77be181d4737839 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts @@ -5,19 +5,21 @@ */ import { buildEmbeddableFilters } from './build_embeddable_filters'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; -const columnFilter: Filter = { +const columnFilter: ExpressionValueFilter = { + type: 'filter', and: [], value: 'filter-value', column: 'filter-column', - type: 'exactly', + filterType: 'exactly', }; -const timeFilter: Filter = { +const timeFilter: ExpressionValueFilter = { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }; diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts index 1a5d2119a94b673..aa915d0d3d02a47 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; // @ts-ignore Untyped Local import { buildBoolArray } from './build_bool_array'; import { @@ -20,9 +20,9 @@ export interface EmbeddableFilterInput { const TimeFilterType = 'time'; -function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { +function getTimeRangeFromFilters(filters: ExpressionValueFilter[]): TimeRange | undefined { const timeFilter = filters.find( - filter => filter.type !== undefined && filter.type === TimeFilterType + filter => filter.filterType !== undefined && filter.filterType === TimeFilterType ); return timeFilter !== undefined && timeFilter.from !== undefined && timeFilter.to !== undefined @@ -33,11 +33,12 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -export function getQueryFilters(filters: Filter[]): DataFilter[] { - return buildBoolArray(filters).map(esFilters.buildQueryFilter); +export function getQueryFilters(filters: ExpressionValueFilter[]): DataFilter[] { + const dataFilters = filters.map(filter => ({ ...filter, type: filter.filterType })); + return buildBoolArray(dataFilters).map(esFilters.buildQueryFilter); } -export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { +export function buildEmbeddableFilters(filters: ExpressionValueFilter[]): EmbeddableFilterInput { return { timeRange: getTimeRangeFromFilters(filters), filters: getQueryFilters(filters), diff --git a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js index e8a4d704118e8bf..7c025ed8dee9b45 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js +++ b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js @@ -14,13 +14,13 @@ import * as filters from './filters'; export function getESFilter(filter) { - if (!filters[filter.type]) { - throw new Error(`Unknown filter type: ${filter.type}`); + if (!filters[filter.filterType]) { + throw new Error(`Unknown filter type: ${filter.filterType}`); } try { - return filters[filter.type](filter); + return filters[filter.filterType](filter); } catch (e) { - throw new Error(`Could not create elasticsearch filter from ${filter.type}`); + throw new Error(`Could not create elasticsearch filter from ${filter.filterType}`); } } diff --git a/x-pack/legacy/plugins/canvas/types/state.ts b/x-pack/legacy/plugins/canvas/types/state.ts index 13c8f7a9176abfa..e9b580f81e668b1 100644 --- a/x-pack/legacy/plugins/canvas/types/state.ts +++ b/x-pack/legacy/plugins/canvas/types/state.ts @@ -6,7 +6,7 @@ import { Datatable, - Filter, + ExpressionValueFilter, ExpressionImage, ExpressionFunction, KibanaContext, @@ -46,7 +46,7 @@ interface ElementStatsType { type ExpressionType = | Datatable - | Filter + | ExpressionValueFilter | ExpressionImage | KibanaContext | KibanaDatatable diff --git a/x-pack/legacy/plugins/maps/server/tutorials/ems/index.ts b/x-pack/legacy/plugins/maps/server/tutorials/ems/index.ts index 88c22d01a527ad1..1006d36afa34d8f 100644 --- a/x-pack/legacy/plugins/maps/server/tutorials/ems/index.ts +++ b/x-pack/legacy/plugins/maps/server/tutorials/ems/index.ts @@ -31,7 +31,7 @@ Indexing EMS administrative boundaries in Elasticsearch allows for search on bou }), euiIconType: 'emsApp', completionTimeMinutes: 1, - previewImagePath: '/plugins/kibana/home/tutorial_resources/ems/boundaries_screenshot.png', + previewImagePath: '/plugins/maps/assets/boundaries_screenshot.png', onPrem: { instructionSets: [ { diff --git a/x-pack/package.json b/x-pack/package.json index dcc9b8c61cb960f..5d1fbaa5784e0b3 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -81,7 +81,7 @@ "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", "@types/lodash": "^3.10.1", - "@types/mapbox-gl": "^1.8.1", + "@types/mapbox-gl": "^1.9.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mocha": "^7.0.2", @@ -280,7 +280,7 @@ "lodash.topath": "^4.5.2", "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", - "mapbox-gl": "^1.9.0", + "mapbox-gl": "^1.10.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", "memoize-one": "^5.0.0", diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 2ba6f9baca90d6f..87ec3f8fc7ec106 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,8 +1,3 @@ -.auaActionWizard__selectedActionFactoryContainer { - background-color: $euiColorLightestShade; - padding: $euiSize; -} - .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 62f16890cade22f..9c73f07289dc901 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,28 +6,26 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; +import { Demo, dashboardFactory, urlFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ( - - )) + .add('default', () => ) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index aea47be693b8fc1..f43d832b1edaeca 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,24 +8,17 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { - dashboardDrilldownActionFactory, - dashboards, - Demo, - urlDrilldownActionFactory, -} from './test_data'; +import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render( - - ); + const screen = render(); // check that all factories are displayed to pick - expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); + expect(screen.getAllByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).toHaveLength(2); // select URL one fireEvent.click(screen.getByText(/Go to URL/i)); @@ -47,11 +40,11 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from - expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); - expect(screen.queryByTestId(TEST_SUBJ_SELECTED_ACTION_FACTORY)).toBeInTheDocument(); + expect(screen.queryByTestId(new RegExp(TEST_SUBJ_ACTION_FACTORY_ITEM))).not.toBeInTheDocument(); + expect(screen.queryByTestId(new RegExp(TEST_SUBJ_SELECTED_ACTION_FACTORY))).toBeInTheDocument(); // Input url const URL = 'https://elastic.co'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index ef4a0f76de9edee..867ead688d23d0c 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,40 +16,20 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; - -// TODO: this interface is temporary for just moving forward with the component -// and it will be imported from the ../ui_actions when implemented properly -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ActionBaseConfig = {}; -export interface ActionFactory { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps { - config?: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; -} +import { ActionFactory } from '../../dynamic_actions'; export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs + actionFactories: ActionFactory[]; /** * Currently selected action factory - * undefined - is allowed and means that non is selected + * undefined - is allowed and means that none is selected */ currentActionFactory?: ActionFactory; + /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -59,12 +39,17 @@ export interface ActionWizardProps { /** * current config for currently selected action factory */ - config?: ActionBaseConfig; + config?: object; /** * config changed */ - onConfigChange: (config: ActionBaseConfig) => void; + onConfigChange: (config: object) => void; + + /** + * Context will be passed into ActionFactory's methods + */ + context: object; } export const ActionWizard: React.FC = ({ @@ -73,6 +58,7 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, + context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -87,6 +73,7 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +84,7 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -105,15 +93,16 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: Config; - onConfigChange: (config: Config) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: object; + context: object; + onConfigChange: (config: object) => void; showDeselect: boolean; onDeselect: () => void; } -export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selected-action-factory'; +export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selectedActionFactory'; const SelectedActionFactory: React.FC = ({ actionFactory, @@ -121,28 +110,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, + context, }) => { return (
- {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( - + )} -

{actionFactory.displayName}

+

{actionFactory.getDisplayName(context)}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -151,10 +140,11 @@ const SelectedActionFactory: React.FC = ({
- {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} +
); @@ -162,14 +152,16 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; + context: object; onActionFactorySelected: (actionFactory: ActionFactory) => void; } -export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; +export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'actionFactoryItem'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, + context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -177,20 +169,30 @@ const ActionFactorySelector: React.FC = ({ return
No action factories to pick from
; } + // The below style is applied to fix Firefox rendering bug. + // See: https://github.com/elastic/kibana/pull/61219/#pullrequestreview-402903330 + const firefoxBugFix = { + willChange: 'opacity', + }; + return ( - - {actionFactories.map(actionFactory => ( - onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && } - - ))} + + {[...actionFactories] + .sort((f1, f2) => f2.order - f1.order) + .map(actionFactory => ( + + onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + + )} + + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index 641f25176264a8a..a315184bf68efad 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'change', + defaultMessage: 'Change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index ed224248ec4cdf7..a189afbf956eecc 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionFactory, ActionWizard } from './action_wizard'; +export { ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 8ecdde681069e8a..c3e749f163c9499 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,124 +6,161 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { ActionWizard } from './action_wizard'; +import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions'; +import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; + +type ActionBaseConfig = object; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -export const dashboardDrilldownActionFactory: ActionFactory<{ +interface DashboardDrilldownConfig { dashboardId?: string; - useCurrentDashboardFilters: boolean; - useCurrentDashboardDataRange: boolean; -}> = { - type: 'Dashboard', - displayName: 'Go to Dashboard', - iconType: 'dashboardApp', + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +function DashboardDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + + + ); +} + +export const dashboardDrilldownActionFactory: ActionFactoryDefinition< + DashboardDrilldownConfig, + any, + any +> = { + id: 'Dashboard', + getDisplayName: () => 'Go to Dashboard', + getIconType: () => 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, + useCurrentFilters: true, + useCurrentDateRange: true, }; }, - isValid: config => { + isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { if (!config.dashboardId) return false; return true; }, - wizard: props => { - const config = props.config ?? { - dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - - - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + order: 0, + create: () => ({ + id: 'test', + execute: async () => alert('Navigate to dashboard!'), + }), }; -export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { - type: 'Url', - displayName: 'Go to URL', - iconType: 'link', +export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); + +interface UrlDrilldownConfig { + url: string; + openInNewTab: boolean; +} +function UrlDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition = { + id: 'Url', + getDisplayName: () => 'Go to URL', + getIconType: () => 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isValid: config => { + isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { if (!config.url) return false; return true; }, - wizard: props => { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Type: {state.currentActionFactory?.type}
+
Action Factory Id: {state.currentActionFactory?.id}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/advanced_ui_actions/public/components/index.ts similarity index 87% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx rename to x-pack/plugins/advanced_ui_actions/public/components/index.ts index 3be289fe6d46e88..236b1a6ec4611ee 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_picker'; +export * from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index 325a5ddc1017935..c0cd8d5540db2bb 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + /** + * Globally unique identifier for this drilldown. + */ + id: string; + + /** + * Determines the display order of the drilldowns in the flyout picker. + * Higher numbers are displayed first. + */ + order?: number; + + /** + * Function that returns default config for this drilldown. + */ + createConfig: ActionFactoryDefinition['createConfig']; + + /** + * `UiComponent` that collections config for this drilldown. You can create + * a React component and transform it `UiComponent` using `uiToReactComponent` + * helper from `kibana_utils` plugin. + * + * ```tsx + * import React from 'react'; + * import { uiToReactComponent } from 'src/plugins/kibana_utils'; + * import { CollectConfigProps } from 'src/plugins/kibana_utils/public'; + * + * type Props = CollectConfigProps; + * + * const ReactCollectConfig: React.FC = () => { + * return
Collecting config...'
; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition['isConfigValid']; + + /** + * Name of EUI icon to display when showing this drilldown to user. + */ + euiIcon?: string; + + /** + * Should return an internationalized name of the drilldown, which will be + * displayed to the user. + */ + getDisplayName: () => string; + + /** + * Implements the "navigation" action of the drilldown. This happens when + * user clicks something in the UI that executes a trigger to which this + * drilldown was attached. + * + * @param config Config object that user configured this drilldown with. + * @param context Object that represents context in which the underlying + * `UIAction` of this drilldown is being executed in. + */ + execute(config: Config, context: ExecutionContext): void; + + /** + * A link where drilldown should navigate on middle click or Ctrl + click. + */ + getHref?(config: Config, context: ExecutionContext): Promise; +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts rename to x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts index ce235043b4ef6e2..7f81a68c803eba6 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './flyout_create_drilldown'; +export * from './drilldown_definition'; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts new file mode 100644 index 000000000000000..f1aef5deff49e09 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public'; +import { + UiActionsActionDefinition as ActionDefinition, + UiActionsPresentable as Presentable, +} from '../../../../../src/plugins/ui_actions/public'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { Configurable } from '../../../../../src/plugins/kibana_utils/public'; +import { SerializedAction } from './types'; + +export class ActionFactory< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> implements Omit, 'getHref'>, Configurable { + constructor( + protected readonly def: ActionFactoryDefinition + ) {} + + public readonly id = this.def.id; + public readonly order = this.def.order || 0; + public readonly MenuItem? = this.def.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public readonly CollectConfig = this.def.CollectConfig; + public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); + public readonly createConfig = this.def.createConfig; + public readonly isConfigValid = this.def.isConfigValid; + + public getIconType(context: FactoryContext): string | undefined { + if (!this.def.getIconType) return undefined; + return this.def.getIconType(context); + } + + public getDisplayName(context: FactoryContext): string { + if (!this.def.getDisplayName) return ''; + return this.def.getDisplayName(context); + } + + public async isCompatible(context: FactoryContext): Promise { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition { + return this.def.create(serializedAction); + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts new file mode 100644 index 000000000000000..d3751fe81166551 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/action_factory_definition.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + UiActionsActionDefinition as ActionDefinition, + UiActionsPresentable as Presentable, +} from '../../../../../src/plugins/ui_actions/public'; +import { Configurable } from '../../../../../src/plugins/kibana_utils/public'; +import { SerializedAction } from './types'; + +/** + * This is a convenience interface for registering new action factories. + */ +export interface ActionFactoryDefinition< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> + extends Partial, 'getHref'>>, + Configurable { + /** + * Unique ID of the action factory. This ID is used to identify this action + * factory in the registry as well as to construct actions of this type and + * identify this action factory when presenting it to the user in UI. + */ + id: string; + + /** + * This method should return a definition of a new action, normally used to + * register it in `ui_actions` registry. + */ + create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts new file mode 100644 index 000000000000000..b7f1b36f8f35869 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.test.ts @@ -0,0 +1,635 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DynamicActionManager } from './dynamic_action_manager'; +import { ActionStorage, MemoryActionStorage } from './dynamic_action_storage'; +import { UiActionsService } from '../../../../../src/plugins/ui_actions/public'; +import { ActionInternal } from '../../../../../src/plugins/ui_actions/public/actions'; +import { of } from '../../../../../src/plugins/kibana_utils'; +import { UiActionsServiceEnhancements } from '../services'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { SerializedAction, SerializedEvent } from './types'; + +const actionFactoryDefinition1: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const actionFactoryDefinition2: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const event1: SerializedEvent = { + eventId: 'EVENT_ID_1', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 1', + config: {}, + }, +}; + +const event2: SerializedEvent = { + eventId: 'EVENT_ID_2', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 2', + config: {}, + }, +}; + +const event3: SerializedEvent = { + eventId: 'EVENT_ID_3', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + name: 'Action 3', + config: {}, + }, +}; + +const setup = (events: readonly SerializedEvent[] = []) => { + const isCompatible = async () => true; + const storage: ActionStorage = new MemoryActionStorage(events); + const actions = new Map(); + const uiActions = new UiActionsService({ + actions, + }); + const uiActionsEnhancements = new UiActionsServiceEnhancements(); + const manager = new DynamicActionManager({ + isCompatible, + storage, + uiActions: { ...uiActions, ...uiActionsEnhancements }, + }); + + uiActions.registerTrigger({ + id: 'VALUE_CLICK_TRIGGER', + }); + + return { + isCompatible, + actions, + storage, + uiActions: { ...uiActions, ...uiActionsEnhancements }, + manager, + }; +}; + +describe('DynamicActionManager', () => { + test('can instantiate', () => { + const { manager } = setup([event1]); + expect(manager).toBeInstanceOf(DynamicActionManager); + }); + + describe('.start()', () => { + test('instantiates stored events', async () => { + const { manager, actions, uiActions } = setup([event1]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(1); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(1); + }); + + test('does nothing when no events stored', async () => { + const { manager, actions, uiActions } = setup(); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + }); + + test('UI state is empty before manager starts', async () => { + const { manager } = setup([event1]); + + expect(manager.state.get()).toMatchObject({ + events: [], + isFetchingEvents: false, + fetchCount: 0, + }); + }); + + test('loads events into UI state', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + await manager.start(); + + expect(manager.state.get()).toMatchObject({ + events: [event1, event2, event3], + isFetchingEvents: false, + fetchCount: 1, + }); + }); + + test('sets isFetchingEvents to true while fetching events', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + const promise = manager.start().catch(() => {}); + + expect(manager.state.get().isFetchingEvents).toBe(true); + + await promise; + + expect(manager.state.get().isFetchingEvents).toBe(false); + }); + + test('throws if storage threw', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + const [, error] = await of(manager.start()); + + expect(error).toEqual(new Error('baz')); + }); + + test('sets UI state error if error happened during initial fetch', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + await of(manager.start()); + + expect(manager.state.get().fetchError!.message).toBe('baz'); + }); + }); + + describe('.stop()', () => { + test('removes events from UI actions registry', async () => { + const { manager, actions, uiActions } = setup([event1, event2]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(actions.size).toBe(0); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.stop(); + + expect(actions.size).toBe(0); + }); + }); + + describe('.createEvent()', () => { + describe('when storage succeeds', () => { + test('stores new event in storage', async () => { + const { manager, storage, uiActions } = setup([]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + expect(await storage.count()).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(await storage.count()).toBe(1); + + const [event] = await storage.list(); + + expect(event).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }, + }); + }); + + test('adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events.length).toBe(1); + }); + + test('optimistically adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(1); + }); + + test('instantiates event in actions service', async () => { + const { manager, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + }); + }); + + describe('when storage fails', () => { + test('throws an error', async () => { + const { manager, storage, uiActions } = setup([]); + + storage.create = async () => { + throw new Error('foo'); + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(error).toEqual(new Error('foo')); + }); + + test('does not add even to UI state', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events.length).toBe(0); + }); + + test('optimistically adds event to UI state and then removes it', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(0); + }); + + test('does not instantiate event in actions service', async () => { + const { manager, storage, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(0); + }); + }); + }); + + describe('.updateEvent()', () => { + describe('when storage succeeds', () => { + test('un-registers old event from ui actions service and registers the new one', async () => { + const { manager, actions, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('foo'); + }); + + test('updates event in storage', async () => { + const { manager, storage, uiActions } = setup([event3]); + const storageUpdateSpy = jest.spyOn(storage, 'update'); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(storageUpdateSpy).toHaveBeenCalledTimes(0); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(storageUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + }, + }); + }); + + test('updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + }); + + test('optimistically updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + const promise = manager + .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + .catch(e => e); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + + await promise; + }); + }); + + describe('when storage fails', () => { + test('throws error', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of( + manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + ); + + expect(error).toEqual(new Error('bar')); + }); + + test('keeps the old action in actions registry', async () => { + const { manager, storage, actions, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('Action 3'); + }); + + test('keeps old event in UI state', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + }); + }); + }); + + describe('.deleteEvents()', () => { + describe('when storage succeeds', () => { + test('removes all actions from uiActions service', async () => { + const { manager, actions, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(actions.size).toBe(0); + }); + + test('removes all events from storage', async () => { + const { manager, uiActions, storage } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(await storage.list()).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(await storage.list()).toEqual([]); + }); + + test('removes all events from UI state', async () => { + const { manager, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(manager.state.get().events).toEqual([]); + }); + }); + }); +}); diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts new file mode 100644 index 000000000000000..df214bfe80cc70c --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager.ts @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { Subscription } from 'rxjs'; +import { ActionStorage } from './dynamic_action_storage'; +import { + TriggerContextMapping, + UiActionsActionDefinition as ActionDefinition, +} from '../../../../../src/plugins/ui_actions/public'; +import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; +import { StateContainer, createStateContainer } from '../../../../../src/plugins/kibana_utils'; +import { StartContract } from '../plugin'; +import { SerializedAction, SerializedEvent } from './types'; + +const compareEvents = ( + a: ReadonlyArray<{ eventId: string }>, + b: ReadonlyArray<{ eventId: string }> +) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; + return true; +}; + +export type DynamicActionManagerState = State; + +export interface DynamicActionManagerParams { + storage: ActionStorage; + uiActions: Pick< + StartContract, + 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' + >; + isCompatible: (context: C) => Promise; +} + +export class DynamicActionManager { + static idPrefixCounter = 0; + + private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; + private stopped: boolean = false; + private reloadSubscription?: Subscription; + + /** + * UI State of the dynamic action manager. + */ + protected readonly ui = createStateContainer(defaultState, transitions, selectors); + + constructor(protected readonly params: DynamicActionManagerParams) {} + + protected getEvent(eventId: string): SerializedEvent { + const oldEvent = this.ui.selectors.getEvent(eventId); + if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); + return oldEvent; + } + + /** + * We prefix action IDs with a unique `.idPrefix`, so we can render the + * same dashboard twice on the screen. + */ + protected generateActionId(eventId: string): string { + return this.idPrefix + eventId; + } + + protected reviveAction(event: SerializedEvent) { + const { eventId, triggers, action } = event; + const { uiActions, isCompatible } = this.params; + + const actionId = this.generateActionId(eventId); + const factory = uiActions.getActionFactory(event.action.factoryId); + const actionDefinition: ActionDefinition = { + ...factory.create(action as SerializedAction), + id: actionId, + isCompatible, + }; + + uiActions.registerAction(actionDefinition); + for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); + } + + protected killAction({ eventId, triggers }: SerializedEvent) { + const { uiActions } = this.params; + const actionId = this.generateActionId(eventId); + + for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); + uiActions.unregisterAction(actionId); + } + + private syncId = 0; + + /** + * This function is called every time stored events might have changed not by + * us. For example, when in edit mode on dashboard user presses "back" button + * in the browser, then contents of storage changes. + */ + private onSync = () => { + if (this.stopped) return; + + (async () => { + const syncId = ++this.syncId; + const events = await this.params.storage.list(); + + if (this.stopped) return; + if (syncId !== this.syncId) return; + if (compareEvents(events, this.ui.get().events)) return; + + for (const event of this.ui.get().events) this.killAction(event); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + })().catch(error => { + /* eslint-disable */ + console.log('Dynamic action manager storage reload failed.'); + console.error(error); + /* eslint-enable */ + }); + }; + + // Public API: --------------------------------------------------------------- + + /** + * Read-only state container of dynamic action manager. Use it to perform all + * *read* operations. + */ + public readonly state: StateContainer = this.ui; + + /** + * 1. Loads all events from @type {DynamicActionStorage} storage. + * 2. Creates actions for each event in `ui_actions` registry. + * 3. Adds events to UI state. + * 4. Does nothing if dynamic action manager was stopped or if event fetching + * is already taking place. + */ + public async start() { + if (this.stopped) return; + if (this.ui.get().isFetchingEvents) return; + + this.ui.transitions.startFetching(); + try { + const events = await this.params.storage.list(); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + } catch (error) { + this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); + throw error; + } + + if (this.params.storage.reload$) { + this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); + } + } + + /** + * 1. Removes all events from `ui_actions` registry. + * 2. Puts dynamic action manager is stopped state. + */ + public async stop() { + this.stopped = true; + const events = await this.params.storage.list(); + + for (const event of events) { + this.killAction(event); + } + + if (this.reloadSubscription) { + this.reloadSubscription.unsubscribe(); + } + } + + /** + * Creates a new event. + * + * 1. Stores event in @type {DynamicActionStorage} storage. + * 2. Optimistically adds it to UI state, and rolls back on failure. + * 3. Adds action to `ui_actions` registry. + * + * @param action Dynamic action for which to create an event. + * @param triggers List of triggers to which action should react. + */ + public async createEvent( + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId: uuidv4(), + triggers, + action, + }; + + this.ui.transitions.addEvent(event); + try { + await this.params.storage.create(event); + this.reviveAction(event); + } catch (error) { + this.ui.transitions.removeEvent(event.eventId); + throw error; + } + } + + /** + * Updates an existing event. Fails if event with given `eventId` does not + * exit. + * + * 1. Updates the event in @type {DynamicActionStorage} storage. + * 2. Optimistically replaces the old event by the new one in UI state, and + * rolls back on failure. + * 3. Replaces action in `ui_actions` registry with the new event. + * + * + * @param eventId ID of the event to replace. + * @param action New action for which to create the event. + * @param triggers List of triggers to which action should react. + */ + public async updateEvent( + eventId: string, + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId, + triggers, + action, + }; + const oldEvent = this.getEvent(eventId); + this.killAction(oldEvent); + + this.reviveAction(event); + this.ui.transitions.replaceEvent(event); + + try { + await this.params.storage.update(event); + } catch (error) { + this.killAction(event); + this.reviveAction(oldEvent); + this.ui.transitions.replaceEvent(oldEvent); + throw error; + } + } + + /** + * Removes existing event. Throws if event does not exist. + * + * 1. Removes the event from @type {DynamicActionStorage} storage. + * 2. Optimistically removes event from UI state, and puts it back on failure. + * 3. Removes associated action from `ui_actions` registry. + * + * @param eventId ID of the event to remove. + */ + public async deleteEvent(eventId: string) { + const event = this.getEvent(eventId); + + this.killAction(event); + this.ui.transitions.removeEvent(eventId); + + try { + await this.params.storage.remove(eventId); + } catch (error) { + this.reviveAction(event); + this.ui.transitions.addEvent(event); + throw error; + } + } + + /** + * Deletes multiple events at once. + * + * @param eventIds List of event IDs. + */ + public async deleteEvents(eventIds: string[]) { + await Promise.all(eventIds.map(this.deleteEvent.bind(this))); + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts new file mode 100644 index 000000000000000..61e8604baa91338 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_manager_state.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SerializedEvent } from './types'; + +/** + * This interface represents the state of @type {DynamicActionManager} at any + * point in time. + */ +export interface State { + /** + * Whether dynamic action manager is currently in process of fetching events + * from storage. + */ + readonly isFetchingEvents: boolean; + + /** + * Number of times event fetching has been completed. + */ + readonly fetchCount: number; + + /** + * Error received last time when fetching events. + */ + readonly fetchError?: { + message: string; + }; + + /** + * List of all fetched events. + */ + readonly events: readonly SerializedEvent[]; +} + +export interface Transitions { + startFetching: (state: State) => () => State; + finishFetching: (state: State) => (events: SerializedEvent[]) => State; + failFetching: (state: State) => (error: { message: string }) => State; + addEvent: (state: State) => (event: SerializedEvent) => State; + removeEvent: (state: State) => (eventId: string) => State; + replaceEvent: (state: State) => (event: SerializedEvent) => State; +} + +export interface Selectors { + getEvent: (state: State) => (eventId: string) => SerializedEvent | null; +} + +export const defaultState: State = { + isFetchingEvents: false, + fetchCount: 0, + events: [], +}; + +export const transitions: Transitions = { + startFetching: state => () => ({ ...state, isFetchingEvents: true }), + + finishFetching: state => events => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: undefined, + events, + }), + + failFetching: state => ({ message }) => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: { message }, + }), + + addEvent: state => (event: SerializedEvent) => ({ + ...state, + events: [...state.events, event], + }), + + removeEvent: state => (eventId: string) => ({ + ...state, + events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, + }), + + replaceEvent: state => event => { + const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index === -1) return state; + + return { + ...state, + events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], + }; + }, +}; + +export const selectors: Selectors = { + getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts new file mode 100644 index 000000000000000..e40441e67f033e7 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/dynamic_action_storage.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable max-classes-per-file */ + +import { Observable, Subject } from 'rxjs'; +import { SerializedEvent } from './types'; + +/** + * This CRUD interface needs to be implemented by dynamic action users if they + * want to persist the dynamic actions. It has a default implementation in + * Embeddables, however one can use the dynamic actions without Embeddables, + * in that case they have to implement this interface. + */ +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable & Pick, 'next'> = new Subject(); + + public async count(): Promise { + return (await this.list()).length; + } + + public async read(eventId: string): Promise { + const events = await this.list(); + const event = events.find(ev => ev.eventId === eventId); + if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); + return event; + } + + abstract create(event: SerializedEvent): Promise; + abstract update(event: SerializedEvent): Promise; + abstract remove(eventId: string): Promise; + abstract list(): Promise; +} + +/** + * This is an in-memory implementation of ActionStorage. It is used in testing, + * but can also be used production code to store events in memory. + */ +export class MemoryActionStorage extends AbstractActionStorage { + constructor(public events: readonly SerializedEvent[] = []) { + super(); + } + + public async list() { + return this.events.map(event => ({ ...event })); + } + + public async create(event: SerializedEvent) { + this.events = [...this.events, { ...event }]; + } + + public async update(event: SerializedEvent) { + const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); + this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; + } + + public async remove(eventId: string) { + const index = this.events.findIndex(ev => eventId === ev.eventId); + if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); + this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; + } +} diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts new file mode 100644 index 000000000000000..bb37cf5e69535b8 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './types'; +export * from './action_factory'; +export * from './action_factory_definition'; +export * from './dynamic_action_storage'; +export * from './dynamic_action_manager_state'; +export * from './dynamic_action_manager'; diff --git a/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts new file mode 100644 index 000000000000000..9148d1ec7055aa1 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/dynamic_actions/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SerializedAction { + readonly factoryId: string; + readonly name: string; + readonly config: Config; +} + +/** + * Serialized representation of a triggers-action pair, used to persist in storage. + */ +export interface SerializedEvent { + eventId: string; + triggers: string[]; + action: SerializedAction; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/index.ts b/x-pack/plugins/advanced_ui_actions/public/index.ts index c11c1119a9b1305..024cfe5530b9712 100644 --- a/x-pack/plugins/advanced_ui_actions/public/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/index.ts @@ -12,3 +12,22 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { AdvancedUiActionsPublicPlugin as Plugin }; +export { + SetupContract as AdvancedUiActionsSetup, + StartContract as AdvancedUiActionsStart, +} from './plugin'; + +export { ActionWizard } from './components'; +export { + ActionFactoryDefinition as AdvancedUiActionsActionFactoryDefinition, + ActionFactory as AdvancedUiActionsActionFactory, + SerializedAction as UiActionsEnhancedSerializedAction, + SerializedEvent as UiActionsEnhancedSerializedEvent, + AbstractActionStorage as UiActionsEnhancedAbstractActionStorage, + DynamicActionManager as UiActionsEnhancedDynamicActionManager, + DynamicActionManagerParams as UiActionsEnhancedDynamicActionManagerParams, + DynamicActionManagerState as UiActionsEnhancedDynamicActionManagerState, + MemoryActionStorage as UiActionsEnhancedMemoryActionStorage, +} from './dynamic_actions'; + +export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns'; diff --git a/x-pack/plugins/advanced_ui_actions/public/mocks.ts b/x-pack/plugins/advanced_ui_actions/public/mocks.ts new file mode 100644 index 000000000000000..65fde12755beb8f --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/mocks.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, CoreStart } from '../../../../src/core/public'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { uiActionsPluginMock } from '../../../../src/plugins/ui_actions/public/mocks'; +import { embeddablePluginMock } from '../../../../src/plugins/embeddable/public/mocks'; +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '.'; +import { plugin as pluginInitializer } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + ...uiActionsPluginMock.createSetupContract(), + registerDrilldown: jest.fn(), + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + ...uiActionsPluginMock.createStartContract(), + getActionFactories: jest.fn(), + getActionFactory: jest.fn(), + }; + + return startContract; +}; + +const createPlugin = ( + coreSetup: CoreSetup = coreMock.createSetup(), + coreStart: CoreStart = coreMock.createStart() +) => { + const pluginInitializerContext = coreMock.createPluginInitializerContext(); + const uiActions = uiActionsPluginMock.createPlugin(); + const embeddable = embeddablePluginMock.createInstance({ + uiActions: uiActions.setup, + }); + const plugin = pluginInitializer(pluginInitializerContext); + const setup = plugin.setup(coreSetup, { + uiActions: uiActions.setup, + embeddable: embeddable.setup, + }); + + return { + pluginInitializerContext, + coreSetup, + coreStart, + plugin, + setup, + doStart: (anotherCoreStart: CoreStart = coreStart) => { + const uiActionsStart = uiActions.doStart(); + const embeddableStart = embeddable.doStart({ + uiActions: uiActionsStart, + }); + return plugin.start(anotherCoreStart, { + uiActions: uiActionsStart, + embeddable: embeddableStart, + }); + }, + }; +}; + +export const uiActionsEnhancedPluginMock = { + createSetupContract, + createStartContract, + createPlugin, +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/plugin.ts b/x-pack/plugins/advanced_ui_actions/public/plugin.ts index b9f0ce43d3cdcb5..f042130158aecf6 100644 --- a/x-pack/plugins/advanced_ui_actions/public/plugin.ts +++ b/x-pack/plugins/advanced_ui_actions/public/plugin.ts @@ -11,7 +11,7 @@ import { Plugin, } from '../../../../src/core/public'; import { createReactOverlays } from '../../../../src/plugins/kibana_react/public'; -import { UiActionsStart, UiActionsSetup } from '../../../../src/plugins/ui_actions/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, @@ -30,6 +30,7 @@ import { TimeBadgeActionContext, } from './custom_time_range_badge'; import { CommonlyUsedRange } from './types'; +import { UiActionsServiceEnhancements } from './services'; interface SetupDependencies { embeddable: EmbeddableSetup; // Embeddable are needed because they register basic triggers/actions. @@ -41,8 +42,13 @@ interface StartDependencies { uiActions: UiActionsStart; } -export type Setup = void; -export type Start = void; +export interface SetupContract + extends UiActionsSetup, + Pick {} + +export interface StartContract + extends UiActionsStart, + Pick {} declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -52,12 +58,19 @@ declare module '../../../../src/plugins/ui_actions/public' { } export class AdvancedUiActionsPublicPlugin - implements Plugin { + implements Plugin { + private readonly enhancements = new UiActionsServiceEnhancements(); + constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} + public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + return { + ...uiActions, + ...this.enhancements, + }; + } - public start(core: CoreStart, { uiActions }: StartDependencies): Start { + public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -66,16 +79,19 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + + return { + ...uiActions, + ...this.enhancements, + }; } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/index.ts new file mode 100644 index 000000000000000..71a3429800c431e --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './ui_actions_service_enhancements'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts new file mode 100644 index 000000000000000..3137e35a2fe4765 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UiActionsServiceEnhancements } from './ui_actions_service_enhancements'; +import { ActionFactoryDefinition, ActionFactory } from '../dynamic_actions'; + +describe('UiActionsService', () => { + describe('action factories', () => { + const factoryDefinition1: ActionFactoryDefinition = { + id: 'test-factory-1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + const factoryDefinition2: ActionFactoryDefinition = { + id: 'test-factory-2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + + test('.getActionFactories() returns empty array if no action factories registered', () => { + const service = new UiActionsServiceEnhancements(); + + const factories = service.getActionFactories(); + + expect(factories).toEqual([]); + }); + + test('can register and retrieve an action factory', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + + const factory = service.getActionFactory(factoryDefinition1.id); + + expect(factory).toBeInstanceOf(ActionFactory); + expect(factory.id).toBe(factoryDefinition1.id); + }); + + test('can retrieve all action factories', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + service.registerActionFactory(factoryDefinition2); + + const factories = service.getActionFactories(); + const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); + + expect(factoriesSorted.length).toBe(2); + expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); + expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); + }); + + test('throws when retrieving action factory that does not exist', () => { + const service = new UiActionsServiceEnhancements(); + + service.registerActionFactory(factoryDefinition1); + + expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( + 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' + ); + }); + }); +}); diff --git a/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts new file mode 100644 index 000000000000000..8befbf43d3c6a2b --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/ui_actions_service_enhancements.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionFactoryRegistry } from '../types'; +import { ActionFactory, ActionFactoryDefinition } from '../dynamic_actions'; +import { DrilldownDefinition } from '../drilldowns'; + +export interface UiActionsServiceEnhancementsParams { + readonly actionFactories?: ActionFactoryRegistry; +} + +export class UiActionsServiceEnhancements { + protected readonly actionFactories: ActionFactoryRegistry; + + constructor({ actionFactories = new Map() }: UiActionsServiceEnhancementsParams = {}) { + this.actionFactories = actionFactories; + } + + /** + * Register an action factory. Action factories are used to configure and + * serialize/deserialize dynamic actions. + */ + public readonly registerActionFactory = < + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object + >( + definition: ActionFactoryDefinition + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + }; + + public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { + const actionFactory = this.actionFactories.get(actionFactoryId); + + if (!actionFactory) { + throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); + } + + return actionFactory; + }; + + /** + * Returns an array of all action factories. + */ + public readonly getActionFactories = (): ActionFactory[] => { + return [...this.actionFactories.values()]; + }; + + /** + * Convenience method to register a {@link DrilldownDefinition | drilldown}. + */ + public readonly registerDrilldown = < + Config extends object = object, + ExecutionContext extends object = object + >({ + id: factoryId, + order, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + euiIcon, + execute, + getHref, + }: DrilldownDefinition): void => { + const actionFactory: ActionFactoryDefinition = { + id: factoryId, + order, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + getIconType: () => euiIcon, + isCompatible: async () => true, + create: serializedAction => ({ + id: '', + type: factoryId, + getIconType: () => euiIcon, + getDisplayName: () => serializedAction.name, + execute: async context => await execute(serializedAction.config, context), + getHref: getHref ? async context => getHref(serializedAction.config, context) : undefined, + }), + } as ActionFactoryDefinition; + + this.registerActionFactory(actionFactory); + }; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/types.ts b/x-pack/plugins/advanced_ui_actions/public/types.ts index 313b09535b196a7..5c960192dcaff9e 100644 --- a/x-pack/plugins/advanced_ui_actions/public/types.ts +++ b/x-pack/plugins/advanced_ui_actions/public/types.ts @@ -5,6 +5,7 @@ */ import { KibanaReactOverlays } from '../../../../src/plugins/kibana_react/public'; +import { ActionFactory } from './dynamic_actions'; export interface CommonlyUsedRange { from: string; @@ -13,3 +14,5 @@ export interface CommonlyUsedRange { } export type OpenModal = KibanaReactOverlays['openModal']; + +export type ActionFactoryRegistry = Map; diff --git a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts index d6520ae1505398f..cd64b3025a65be3 100644 --- a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts +++ b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -interface AmountAndUnit { - amount: string; +export interface AmountAndUnit { + amount: number; unit: string; } export function amountAndUnitToObject(value: string): AmountAndUnit { // matches any postive and negative number and its unit. const [, amount = '', unit = ''] = value.match(/(^-?\d+)?(\w+)?/) || []; - return { amount, unit }; + return { amount: parseInt(amount, 10), unit }; } -export function amountAndUnitToString({ amount, unit }: AmountAndUnit) { +export function amountAndUnitToString({ + amount, + unit +}: Omit & { amount: string | number }) { return `${amount}${unit}`; } diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts index a0b1d5015b9ef83..e903a56486b6eb4 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts @@ -6,11 +6,11 @@ import * as t from 'io-ts'; import { settingDefinitions } from '../setting_definitions'; +import { SettingValidation } from '../setting_definitions/types'; // retrieve validation from config definitions settings and validate on the server const knownSettings = settingDefinitions.reduce< - // TODO: is it possible to get rid of any? - Record> + Record >((acc, { key, validation }) => { acc[key] = validation; return acc; diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts index 596037645c00243..4d786605b00c720 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts @@ -4,35 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bytesRt } from './bytes_rt'; +import { getBytesRt } from './bytes_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; describe('bytesRt', () => { - describe('it should not accept', () => { - [ - undefined, - null, - '', - 0, - 'foo', - true, - false, - '100', - 'mb', - '0kb', - '5gb', - '6tb' - ].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(bytesRt.decode(input))).toBe(false); + describe('must accept any amount and unit', () => { + const bytesRt = getBytesRt({}); + describe('it should not accept', () => { + ['mb', 1, '1', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['-1b', '0mb', '1b', '2kb', '3mb', '1000mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); }); }); }); + describe('must be at least 0b', () => { + const bytesRt = getBytesRt({ + min: '0b' + }); + + describe('it should not accept', () => { + ['mb', '-1kb', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should return correct error message', () => { + ['-1kb', '5gb', '6tb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = bytesRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be greater than 0b'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); - describe('it should accept', () => { - ['1b', '2kb', '3mb'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(bytesRt.decode(input))).toBe(true); + describe('it should accept', () => { + ['1b', '2kb', '3mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); + }); + }); + }); + describe('must be between 500b and 1kb', () => { + const bytesRt = getBytesRt({ + min: '500b', + max: '1kb' + }); + describe('it should not accept', () => { + ['mb', '-1b', '1b', '499b', '1025b', '2kb', '1mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + describe('it should return correct error message', () => { + ['-1b', '1b', '499b', '1025b', '2kb', '1mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = bytesRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 500b and 1kb'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['500b', '1024b', '1kb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts index d189fab89ae5d1a..9f49527438b4993 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts @@ -7,27 +7,50 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; import { amountAndUnitToObject } from '../amount_and_unit'; +import { getRangeTypeMessage } from './get_range_type_message'; -export const BYTE_UNITS = ['b', 'kb', 'mb']; +function toBytes(amount: number, unit: string) { + switch (unit) { + case 'b': + return amount; + case 'kb': + return amount * 2 ** 10; + case 'mb': + return amount * 2 ** 20; + } +} -export const bytesRt = new t.Type( - 'bytesRt', - t.string.is, - (input, context) => { - return either.chain(t.string.validate(input, context), inputAsString => { - const { amount, unit } = amountAndUnitToObject(inputAsString); - const amountAsInt = parseInt(amount, 10); - const isValidUnit = BYTE_UNITS.includes(unit); - const isValid = amountAsInt > 0 && isValidUnit; +function amountAndUnitToBytes(value?: string): number | undefined { + if (value) { + const { amount, unit } = amountAndUnitToObject(value); + if (isFinite(amount) && unit) { + return toBytes(amount, unit); + } + } +} - return isValid - ? t.success(inputAsString) - : t.failure( - input, - context, - `Must have numeric amount and a valid unit (${BYTE_UNITS})` - ); - }); - }, - t.identity -); +export function getBytesRt({ min, max }: { min?: string; max?: string }) { + const minAsBytes = amountAndUnitToBytes(min) ?? -Infinity; + const maxAsBytes = amountAndUnitToBytes(max) ?? Infinity; + const message = getRangeTypeMessage(min, max); + + return new t.Type( + 'bytesRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsBytes = amountAndUnitToBytes(inputAsString); + + const isValidAmount = + inputAsBytes !== undefined && + inputAsBytes >= minAsBytes && + inputAsBytes <= maxAsBytes; + + return isValidAmount + ? t.success(inputAsString) + : t.failure(input, context, message); + }); + }, + t.identity + ); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts index 98d0cb5f028c335..ebfd9d9a727044b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts @@ -4,62 +4,122 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { durationRt, getDurationRt } from './duration_rt'; +import { getDurationRt } from './duration_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; -describe('durationRt', () => { - describe('it should not accept', () => { - [ - undefined, - null, - '', - 0, - 'foo', - true, - false, - '100', - 's', - 'm', - '0ms', - '-1ms' - ].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(durationRt.decode(input))).toBe(false); +describe('getDurationRt', () => { + describe('must be at least 1m', () => { + const customDurationRt = getDurationRt({ min: '1m' }); + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '0m', + '-1m', + '1ms', + '1s' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); }); }); - }); - - describe('it should accept', () => { - ['1000ms', '2s', '3m', '1s'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(durationRt.decode(input))).toBe(true); + describe('it should return correct error message', () => { + ['0m', '-1m', '1ms', '1s'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be greater than 1m'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1m', '2m', '1000m'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); + }); }); }); }); -}); -describe('getDurationRt', () => { - const customDurationRt = getDurationRt({ min: -1 }); - describe('it should not accept', () => { - [undefined, null, '', 0, 'foo', true, false, '100', 's', 'm', '-2ms'].map( - input => { + describe('must be between 1ms and 1s', () => { + const customDurationRt = getDurationRt({ min: '1ms', max: '1s' }); + + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '-1s', + '0s', + '2s', + '1001ms', + '0ms', + '-1ms', + '0m', + '1m' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); + }); + }); + describe('it should return correct error message', () => { + ['-1s', '0s', '2s', '1001ms', '0ms', '-1ms', '0m', '1m'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 1ms and 1s'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1s', '1ms', '50ms', '1000ms'].map(input => { it(`${JSON.stringify(input)}`, () => { - expect(isRight(customDurationRt.decode(input))).toBe(false); + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); }); - } - ); + }); + }); }); + describe('must be max 1m', () => { + const customDurationRt = getDurationRt({ max: '1m' }); - describe('it should accept', () => { - ['1000ms', '2s', '3m', '1s', '-1s', '0ms'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(customDurationRt.decode(input))).toBe(true); + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false, '2m', '61s', '60001ms'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeFalsy(); + }); + } + ); + }); + describe('it should return correct error message', () => { + ['2m', '61s', '60001ms'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = customDurationRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be less than 1m'); + expect(isRight(result)).toBeFalsy(); + }); + }); + }); + describe('it should accept', () => { + ['1m', '0m', '-1m', '60s', '6000ms', '1ms', '1s'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customDurationRt.decode(input))).toBeTruthy(); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts index b691276854fb0ae..cede5ed2625587b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts @@ -6,32 +6,45 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; -import { amountAndUnitToObject } from '../amount_and_unit'; +import moment, { unitOfTime } from 'moment'; +import { amountAndUnitToObject, AmountAndUnit } from '../amount_and_unit'; +import { getRangeTypeMessage } from './get_range_type_message'; -export const DURATION_UNITS = ['ms', 's', 'm']; +function toMilliseconds({ amount, unit }: AmountAndUnit) { + return moment.duration(amount, unit as unitOfTime.Base); +} + +function amountAndUnitToMilliseconds(value?: string) { + if (value) { + const { amount, unit } = amountAndUnitToObject(value); + if (isFinite(amount) && unit) { + return toMilliseconds({ amount, unit }); + } + } +} + +export function getDurationRt({ min, max }: { min?: string; max?: string }) { + const minAsMilliseconds = amountAndUnitToMilliseconds(min) ?? -Infinity; + const maxAsMilliseconds = amountAndUnitToMilliseconds(max) ?? Infinity; + const message = getRangeTypeMessage(min, max); -export function getDurationRt({ min }: { min: number }) { return new t.Type( 'durationRt', t.string.is, (input, context) => { return either.chain(t.string.validate(input, context), inputAsString => { - const { amount, unit } = amountAndUnitToObject(inputAsString); - const amountAsInt = parseInt(amount, 10); - const isValidUnit = DURATION_UNITS.includes(unit); - const isValid = amountAsInt >= min && isValidUnit; + const inputAsMilliseconds = amountAndUnitToMilliseconds(inputAsString); + + const isValidAmount = + inputAsMilliseconds !== undefined && + inputAsMilliseconds >= minAsMilliseconds && + inputAsMilliseconds <= maxAsMilliseconds; - return isValid + return isValidAmount ? t.success(inputAsString) - : t.failure( - input, - context, - `Must have numeric amount and a valid unit (${DURATION_UNITS})` - ); + : t.failure(input, context, message); }); }, t.identity ); } - -export const durationRt = getDurationRt({ min: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts new file mode 100644 index 000000000000000..82fb8ee068b3034 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { floatRt } from './float_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('floatRt', () => { + it('does not accept empty values', () => { + expect(isRight(floatRt.decode(undefined))).toBe(false); + expect(isRight(floatRt.decode(null))).toBe(false); + expect(isRight(floatRt.decode(''))).toBe(false); + }); + + it('should only accept stringified numbers', () => { + expect(isRight(floatRt.decode('0.5'))).toBe(true); + expect(isRight(floatRt.decode(0.5))).toBe(false); + }); + + it('checks if the number falls within 0, 1', () => { + expect(isRight(floatRt.decode('0'))).toBe(true); + expect(isRight(floatRt.decode('0.5'))).toBe(true); + expect(isRight(floatRt.decode('-0.1'))).toBe(false); + expect(isRight(floatRt.decode('1.1'))).toBe(false); + expect(isRight(floatRt.decode(NaN))).toBe(false); + }); + + it('checks whether the number of decimals is 3', () => { + expect(isRight(floatRt.decode('1'))).toBe(true); + expect(isRight(floatRt.decode('0.9'))).toBe(true); + expect(isRight(floatRt.decode('0.99'))).toBe(true); + expect(isRight(floatRt.decode('0.999'))).toBe(true); + expect(isRight(floatRt.decode('0.9999'))).toBe(false); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts new file mode 100644 index 000000000000000..4aa166f84bfe951 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/float_rt.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export const floatRt = new t.Type( + 'floatRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsFloat = parseFloat(inputAsString); + const maxThreeDecimals = + parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; + + const isValid = + inputAsFloat >= 0 && inputAsFloat <= 1 && maxThreeDecimals; + + return isValid + ? t.success(inputAsString) + : t.failure(input, context, 'Must be a number between 0.000 and 1'); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts new file mode 100644 index 000000000000000..5bd0fcb80c4dde0 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/get_range_type_message.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isFinite } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +function getRangeType(min?: number, max?: number) { + if (isFinite(min) && isFinite(max)) { + return 'between'; + } else if (isFinite(min)) { + return 'gt'; // greater than + } else if (isFinite(max)) { + return 'lt'; // less than + } +} + +export function getRangeTypeMessage( + min?: number | string, + max?: number | string +) { + return i18n.translate('xpack.apm.agentConfig.range.errorText', { + defaultMessage: `{rangeType, select, + between {Must be between {min} and {max}} + gt {Must be greater than {min}} + lt {Must be less than {max}} + other {Must be an integer} + }`, + values: { + min, + max, + rangeType: getRangeType( + typeof min === 'string' ? amountAndUnitToObject(min).amount : min, + typeof max === 'string' ? amountAndUnitToObject(max).amount : max + ) + } + }); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts index ef7fbeed4331ed9..a0395a4a140d977 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts @@ -4,43 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import { integerRt, getIntegerRt } from './integer_rt'; +import { getIntegerRt } from './integer_rt'; import { isRight } from 'fp-ts/lib/Either'; +import { PathReporter } from 'io-ts/lib/PathReporter'; -describe('integerRt', () => { - describe('it should not accept', () => { - [undefined, null, '', 'foo', 0, 55, NaN].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(integerRt.decode(input))).toBe(false); +describe('getIntegerRt', () => { + describe('with range', () => { + const integerRt = getIntegerRt({ + min: 0, + max: 32000 + }); + + describe('it should not accept', () => { + [NaN, undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('it should return correct error message', () => { + ['-1', '-55', '33000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + const result = integerRt.decode(input); + const message = PathReporter.report(result)[0]; + expect(message).toEqual('Must be between 0 and 32000'); + expect(isRight(result)).toBeFalsy(); + }); }); }); - }); - describe('it should accept', () => { - ['-1234', '-1', '0', '1000', '32000', '100000'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(integerRt.decode(input))).toBe(true); + describe('it should accept number between 0 and 32000', () => { + ['0', '1000', '32000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); }); }); }); -}); -describe('getIntegerRt', () => { - const customIntegerRt = getIntegerRt({ min: 0, max: 32000 }); - describe('it should not accept', () => { - [undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000', NaN].map( - input => { + describe('without range', () => { + const integerRt = getIntegerRt(); + + describe('it should not accept', () => { + [NaN, undefined, null, '', 'foo', 0, 55].map(input => { it(`${JSON.stringify(input)}`, () => { - expect(isRight(customIntegerRt.decode(input))).toBe(false); + expect(isRight(integerRt.decode(input))).toBe(false); }); - } - ); - }); + }); + }); - describe('it should accept', () => { - ['0', '1000', '32000'].map(input => { - it(`${JSON.stringify(input)}`, () => { - expect(isRight(customIntegerRt.decode(input))).toBe(true); + describe('it should accept any number', () => { + ['-100', '-1', '0', '1000', '32000', '100000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); }); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts index 6dbf175c8b4ceb8..adb91992f756af7 100644 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts @@ -6,8 +6,17 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; +import { getRangeTypeMessage } from './get_range_type_message'; + +export function getIntegerRt({ + min = -Infinity, + max = Infinity +}: { + min?: number; + max?: number; +} = {}) { + const message = getRangeTypeMessage(min, max); -export function getIntegerRt({ min, max }: { min: number; max: number }) { return new t.Type( 'integerRt', t.string.is, @@ -17,15 +26,9 @@ export function getIntegerRt({ min, max }: { min: number; max: number }) { const isValid = inputAsInt >= min && inputAsInt <= max; return isValid ? t.success(inputAsString) - : t.failure( - input, - context, - `Number must be a valid number between ${min} and ${max}` - ); + : t.failure(input, context, message); }); }, t.identity ); } - -export const integerRt = getIntegerRt({ min: -Infinity, max: Infinity }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts deleted file mode 100644 index ece229ca162fb42..000000000000000 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { numberFloatRt } from './number_float_rt'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('numberFloatRt', () => { - it('does not accept empty values', () => { - expect(isRight(numberFloatRt.decode(undefined))).toBe(false); - expect(isRight(numberFloatRt.decode(null))).toBe(false); - expect(isRight(numberFloatRt.decode(''))).toBe(false); - }); - - it('should only accept stringified numbers', () => { - expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); - expect(isRight(numberFloatRt.decode(0.5))).toBe(false); - }); - - it('checks if the number falls within 0, 1', () => { - expect(isRight(numberFloatRt.decode('0'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); - expect(isRight(numberFloatRt.decode('-0.1'))).toBe(false); - expect(isRight(numberFloatRt.decode('1.1'))).toBe(false); - expect(isRight(numberFloatRt.decode(NaN))).toBe(false); - }); - - it('checks whether the number of decimals is 3', () => { - expect(isRight(numberFloatRt.decode('1'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.9'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.99'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.999'))).toBe(true); - expect(isRight(numberFloatRt.decode('0.9999'))).toBe(false); - }); -}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts deleted file mode 100644 index f1890c9851a3d6f..000000000000000 --- a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from 'io-ts'; -import { either } from 'fp-ts/lib/Either'; - -export function getNumberFloatRt({ min, max }: { min: number; max: number }) { - return new t.Type( - 'numberFloatRt', - t.string.is, - (input, context) => { - return either.chain(t.string.validate(input, context), inputAsString => { - const inputAsFloat = parseFloat(inputAsString); - const maxThreeDecimals = - parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; - - const isValid = - inputAsFloat >= min && inputAsFloat <= max && maxThreeDecimals; - - return isValid - ? t.success(inputAsString) - : t.failure( - input, - context, - `Number must be between ${min} and ${max}` - ); - }); - }, - t.identity - ); -} - -export const numberFloatRt = getNumberFloatRt({ min: 0, max: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap index ea706be9f584a6a..4f5763dcde582d7 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -4,24 +4,24 @@ exports[`settingDefinitions should have correct default values 1`] = ` Array [ Object { "key": "api_request_size", + "min": "0b", "type": "bytes", "units": Array [ "b", "kb", "mb", ], - "validationError": "Please specify an integer and a unit", "validationName": "bytesRt", }, Object { "key": "api_request_time", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { @@ -84,24 +84,25 @@ Array [ }, Object { "key": "profiling_inferred_spans_min_duration", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "profiling_inferred_spans_sampling_interval", + "max": "1s", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { @@ -111,81 +112,75 @@ Array [ }, Object { "key": "server_timeout", + "min": "1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "span_frames_min_duration", - "min": -1, + "min": "-1ms", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "stack_trace_limit", + "max": undefined, + "min": undefined, "type": "integer", - "validationError": "Must be an integer", "validationName": "integerRt", }, Object { "key": "stress_monitor_cpu_duration_threshold", + "min": "1m", "type": "duration", "units": Array [ "ms", "s", "m", ], - "validationError": "Please specify an integer and a unit", "validationName": "durationRt", }, Object { "key": "stress_monitor_gc_relief_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_gc_stress_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_system_cpu_relief_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "stress_monitor_system_cpu_stress_threshold", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, Object { "key": "transaction_max_spans", "max": 32000, "min": 0, "type": "integer", - "validationError": "Must be between 0 and 32000", "validationName": "integerRt", }, Object { "key": "transaction_sample_rate", "type": "float", - "validationError": "Must be a number between 0.000 and 1", - "validationName": "numberFloatRt", + "validationName": "floatRt", }, ] `; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 7477238ba79ae06..4ade59d4890402e 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -5,14 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { getIntegerRt } from '../runtime_types/integer_rt'; import { captureBodyRt } from '../runtime_types/capture_body_rt'; import { RawSettingDefinition } from './types'; -import { getDurationRt } from '../runtime_types/duration_rt'; -/* - * Settings added here will show up in the UI and will be validated on the client and server - */ export const generalSettings: RawSettingDefinition[] = [ // API Request Size { @@ -144,7 +139,7 @@ export const generalSettings: RawSettingDefinition[] = [ { key: 'span_frames_min_duration', type: 'duration', - validation: getDurationRt({ min: -1 }), + min: '-1ms', defaultValue: '5ms', label: i18n.translate('xpack.apm.agentConfig.spanFramesMinDuration.label', { defaultMessage: 'Span frames minimum duration' @@ -156,8 +151,7 @@ export const generalSettings: RawSettingDefinition[] = [ 'In its default settings, the APM agent will collect a stack trace with every recorded span.\nWhile this is very helpful to find the exact place in your code that causes the span, collecting this stack trace does have some overhead. \nWhen setting this option to a negative value, like `-1ms`, stack traces will be collected for all spans. Setting it to a positive value, e.g. `5ms`, will limit stack trace collection to spans with durations equal to or longer than the given value, e.g. 5 milliseconds.\n\nTo disable stack trace collection for spans completely, set the value to `0ms`.' } ), - excludeAgents: ['js-base', 'rum-js', 'nodejs'], - min: -1 + excludeAgents: ['js-base', 'rum-js', 'nodejs'] }, // STACK_TRACE_LIMIT @@ -182,11 +176,8 @@ export const generalSettings: RawSettingDefinition[] = [ { key: 'transaction_max_spans', type: 'integer', - validation: getIntegerRt({ min: 0, max: 32000 }), - validationError: i18n.translate( - 'xpack.apm.agentConfig.transactionMaxSpans.errorText', - { defaultMessage: 'Must be between 0 and 32000' } - ), + min: 0, + max: 32000, defaultValue: '500', label: i18n.translate('xpack.apm.agentConfig.transactionMaxSpans.label', { defaultMessage: 'Transaction max spans' @@ -198,8 +189,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Limits the amount of spans that are recorded per transaction.' } ), - min: 0, - max: 32000, excludeAgents: ['js-base', 'rum-js'] }, diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts index 8786a94be096d40..7869cd7d79e17a1 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts @@ -7,58 +7,75 @@ import * as t from 'io-ts'; import { sortBy } from 'lodash'; import { isRight } from 'fp-ts/lib/Either'; -import { i18n } from '@kbn/i18n'; +import { PathReporter } from 'io-ts/lib/PathReporter'; import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; import { booleanRt } from '../runtime_types/boolean_rt'; -import { integerRt } from '../runtime_types/integer_rt'; +import { getIntegerRt } from '../runtime_types/integer_rt'; import { isRumAgentName } from '../../agent_name'; -import { numberFloatRt } from '../runtime_types/number_float_rt'; -import { bytesRt, BYTE_UNITS } from '../runtime_types/bytes_rt'; -import { durationRt, DURATION_UNITS } from '../runtime_types/duration_rt'; +import { floatRt } from '../runtime_types/float_rt'; import { RawSettingDefinition, SettingDefinition } from './types'; import { generalSettings } from './general_settings'; import { javaSettings } from './java_settings'; +import { getDurationRt } from '../runtime_types/duration_rt'; +import { getBytesRt } from '../runtime_types/bytes_rt'; + +function getSettingDefaults(setting: RawSettingDefinition): SettingDefinition { + switch (setting.type) { + case 'select': + return { validation: t.string, ...setting }; -function getDefaultsByType(settingDefinition: RawSettingDefinition) { - switch (settingDefinition.type) { case 'boolean': - return { validation: booleanRt }; + return { validation: booleanRt, ...setting }; + case 'text': - return { validation: t.string }; - case 'integer': + return { validation: t.string, ...setting }; + + case 'integer': { + const { min, max } = setting; + return { - validation: integerRt, - validationError: i18n.translate( - 'xpack.apm.agentConfig.integer.errorText', - { defaultMessage: 'Must be an integer' } - ) + validation: getIntegerRt({ min, max }), + min, + max, + ...setting }; - case 'float': + } + + case 'float': { return { - validation: numberFloatRt, - validationError: i18n.translate( - 'xpack.apm.agentConfig.float.errorText', - { defaultMessage: 'Must be a number between 0.000 and 1' } - ) + validation: floatRt, + ...setting }; - case 'bytes': + } + + case 'bytes': { + const units = setting.units ?? ['b', 'kb', 'mb']; + const min = setting.min ?? '0b'; + const max = setting.max; + return { - validation: bytesRt, - units: BYTE_UNITS, - validationError: i18n.translate( - 'xpack.apm.agentConfig.bytes.errorText', - { defaultMessage: 'Please specify an integer and a unit' } - ) + validation: getBytesRt({ min, max }), + units, + min, + ...setting }; - case 'duration': + } + + case 'duration': { + const units = setting.units ?? ['ms', 's', 'm']; + const min = setting.min ?? '1ms'; + const max = setting.max; + return { - validation: durationRt, - units: DURATION_UNITS, - validationError: i18n.translate( - 'xpack.apm.agentConfig.bytes.errorText', - { defaultMessage: 'Please specify an integer and a unit' } - ) + validation: getDurationRt({ min, max }), + units, + min, + ...setting }; + } + + default: + return setting; } } @@ -91,23 +108,14 @@ export function filterByAgent(agentName?: AgentName) { }; } -export function isValid(setting: SettingDefinition, value: unknown) { - return isRight(setting.validation.decode(value)); +export function validateSetting(setting: SettingDefinition, value: unknown) { + const result = setting.validation.decode(value); + const message = PathReporter.report(result)[0]; + const isValid = isRight(result); + return { isValid, message }; } -export const settingDefinitions = sortBy( - [...generalSettings, ...javaSettings].map(def => { - const defWithDefaults = { - ...getDefaultsByType(def), - ...def - }; - - // ensure every option has validation - if (!defWithDefaults.validation) { - throw new Error(`Missing validation for ${def.key}`); - } - - return defWithDefaults as SettingDefinition; - }), +export const settingDefinitions: SettingDefinition[] = sortBy( + [...generalSettings, ...javaSettings].map(getSettingDefaults), 'key' ); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts index 2e10c7437854941..bc8f19becf05365 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts @@ -99,7 +99,8 @@ export const javaSettings: RawSettingDefinition[] = [ 'The minimal time required in order to determine whether the system is either currently under stress, or that the stress detected previously has been relieved. All measurements during this time must be consistent in comparison to the relevant threshold in order to detect a change of stress state. Must be at least `1m`.' } ), - includeAgents: ['java'] + includeAgents: ['java'], + min: '1m' }, { key: 'stress_monitor_system_cpu_stress_threshold', @@ -176,7 +177,9 @@ export const javaSettings: RawSettingDefinition[] = [ 'The frequency at which stack traces are gathered within a profiling session. The lower you set it, the more accurate the durations will be. This comes at the expense of higher overhead and more spans for potentially irrelevant operations. The minimal duration of a profiling-inferred span is the same as the value of this setting.' } ), - includeAgents: ['java'] + includeAgents: ['java'], + min: '1ms', + max: '1s' }, { key: 'profiling_inferred_spans_min_duration', diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts index 815b8cb3d4e83df..85a454b5f256a17 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts @@ -7,6 +7,9 @@ import * as t from 'io-ts'; import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +// TODO: is it possible to get rid of `any`? +export type SettingValidation = t.Type; + interface BaseSetting { /** * UI: unique key to identify setting @@ -25,7 +28,7 @@ interface BaseSetting { category?: string; /** - * UI: + * UI: Default value set by agent */ defaultValue?: string; @@ -39,16 +42,6 @@ interface BaseSetting { */ placeholder?: string; - /** - * runtime validation of the input - */ - validation?: t.Type; - - /** - * UI: error shown when the runtime validation fails - */ - validationError?: string; - /** * Limits the setting to no agents, except those specified in `includeAgents` */ @@ -62,36 +55,41 @@ interface BaseSetting { interface TextSetting extends BaseSetting { type: 'text'; -} - -interface IntegerSetting extends BaseSetting { - type: 'integer'; - min?: number; - max?: number; -} - -interface FloatSetting extends BaseSetting { - type: 'float'; + validation?: SettingValidation; } interface SelectSetting extends BaseSetting { type: 'select'; options: Array<{ text: string; value: string }>; + validation?: SettingValidation; } interface BooleanSetting extends BaseSetting { type: 'boolean'; } +interface FloatSetting extends BaseSetting { + type: 'float'; +} + +interface IntegerSetting extends BaseSetting { + type: 'integer'; + min?: number; + max?: number; +} + interface BytesSetting extends BaseSetting { type: 'bytes'; + min?: string; + max?: string; units?: string[]; } interface DurationSetting extends BaseSetting { type: 'duration'; + min?: string; + max?: string; units?: string[]; - min?: number; } export type RawSettingDefinition = @@ -104,5 +102,8 @@ export type RawSettingDefinition = | DurationSetting; export type SettingDefinition = RawSettingDefinition & { - validation: NonNullable; + /** + * runtime validation of input + */ + validation: SettingValidation; }; diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/apm/apm.png b/x-pack/plugins/apm/public/assets/apm.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/apm/apm.png rename to x-pack/plugins/apm/public/assets/apm.png diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx index fcd75a05b01d9ec..6711fecc2376c64 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { SettingDefinition } from '../../../../../../../common/agent_configuration/setting_definitions/types'; -import { isValid } from '../../../../../../../common/agent_configuration/setting_definitions'; +import { validateSetting } from '../../../../../../../common/agent_configuration/setting_definitions'; import { amountAndUnitToString, amountAndUnitToObject @@ -92,12 +92,14 @@ function FormRow({ onChange( setting.key, - amountAndUnitToString({ amount: e.target.value, unit }) + amountAndUnitToString({ + amount: e.target.value, + unit + }) ) } /> @@ -137,7 +139,8 @@ export function SettingFormRow({ value?: string; onChange: (key: string, value: string) => void; }) { - const isInvalid = value != null && value !== '' && !isValid(setting, value); + const { isValid, message } = validateSetting(setting, value); + const isInvalid = value != null && value !== '' && !isValid; return ( } > - + diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx index e41bdaf0c9c09fb..bb3c2b3249363ab 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx @@ -29,7 +29,7 @@ import { AgentConfigurationIntake } from '../../../../../../../common/agent_conf import { filterByAgent, settingDefinitions, - isValid + validateSetting } from '../../../../../../../common/agent_configuration/setting_definitions'; import { saveConfig } from './saveConfig'; import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; @@ -79,7 +79,7 @@ export function SettingsPage({ // every setting must be valid for the form to be valid .every(def => { const value = newConfig.settings[def.key]; - return isValid(def, value); + return validateSetting(def, value).isValid; }) ); }, [newConfig.settings]); diff --git a/x-pack/plugins/apm/server/tutorial/index.ts b/x-pack/plugins/apm/server/tutorial/index.ts index 1fbac0b6495df8f..76e2456afa5df7c 100644 --- a/x-pack/plugins/apm/server/tutorial/index.ts +++ b/x-pack/plugins/apm/server/tutorial/index.ts @@ -13,6 +13,7 @@ import { ArtifactsSchema, TutorialsCategory } from '../../../../../src/plugins/home/server'; +import { APM_STATIC_INDEX_PATTERN_ID } from '../../common/index_pattern_constants'; const apmIntro = i18n.translate('xpack.apm.tutorial.introduction', { defaultMessage: @@ -39,6 +40,7 @@ export const tutorialProvider = ({ const savedObjects = [ { ...apmIndexPattern, + id: APM_STATIC_INDEX_PATTERN_ID, attributes: { ...apmIndexPattern.attributes, title: indexPatternTitle @@ -98,7 +100,7 @@ It allows you to monitor the performance of thousands of applications in real ti artifacts, onPrem: onPremInstructions(indices), elasticCloud: createElasticCloudInstructions(cloud), - previewImagePath: '/plugins/kibana/home/tutorial_resources/apm/apm.png', + previewImagePath: '/plugins/apm/assets/apm.png', savedObjects, savedObjectsInstallMsg: i18n.translate( 'xpack.apm.tutorial.specProvider.savedObjectsInstallMsg', diff --git a/x-pack/plugins/apm/typings/apm_rum_react.d.ts b/x-pack/plugins/apm/typings/apm_rum_react.d.ts index 6f500caabd8247a..1c3e41ec1278095 100644 --- a/x-pack/plugins/apm/typings/apm_rum_react.d.ts +++ b/x-pack/plugins/apm/typings/apm_rum_react.d.ts @@ -3,5 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -declare module '@elastic/apm-rum-react'; +declare module '@elastic/apm-rum-react' { + export const ApmRoute: any; +} diff --git a/x-pack/plugins/apm/typings/common.d.ts b/x-pack/plugins/apm/typings/common.d.ts index eeeb85cd1e7c3ad..754529a19855286 100644 --- a/x-pack/plugins/apm/typings/common.d.ts +++ b/x-pack/plugins/apm/typings/common.d.ts @@ -8,6 +8,7 @@ import '../../../typings/rison_node'; import '../../infra/types/eui'; // EUIBasicTable import '../../reporting/public/components/report_listing'; +import './apm_rum_react'; // Allow unknown properties in an object export type AllowUnknownProperties = T extends Array diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 000000000000000..d9296ae158621da --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json new file mode 100644 index 000000000000000..f416ca97f711074 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardEnhanced", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["data", "advancedUiActions", "drilldowns", "embeddable", "dashboard", "share"], + "configPath": ["xpack", "dashboardEnhanced"] +} diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts new file mode 100644 index 000000000000000..53540a4a1ad2e93 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { DashboardEnhancedPlugin } from './plugin'; + +export { + SetupContract as DashboardEnhancedSetupContract, + SetupDependencies as DashboardEnhancedSetupDependencies, + StartContract as DashboardEnhancedStartContract, + StartDependencies as DashboardEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new DashboardEnhancedPlugin(context); +} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts new file mode 100644 index 000000000000000..67dc1fd97d52133 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const dashboardEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts new file mode 100644 index 000000000000000..772e032289bcee3 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { SharePluginStart, SharePluginSetup } from '../../../../src/plugins/share/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { DashboardDrilldownsService } from './services'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; + +export interface SetupDependencies { + advancedUiActions: AdvancedUiActionsSetup; + drilldowns: DrilldownsSetup; + embeddable: EmbeddableSetup; + share: SharePluginSetup; +} + +export interface StartDependencies { + advancedUiActions: AdvancedUiActionsStart; + data: DataPublicPluginStart; + drilldowns: DrilldownsStart; + embeddable: EmbeddableStart; + share: SharePluginStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class DashboardEnhancedPlugin + implements Plugin { + public readonly drilldowns = new DashboardDrilldownsService(); + + constructor(protected readonly context: PluginInitializerContext) {} + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.drilldowns.bootstrap(core, plugins, { + enableDrilldowns: true, + }); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx new file mode 100644 index 000000000000000..5ec1b881317d69b --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, +} from './flyout_create_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); + +const actionParams: OpenFlyoutAddDrilldownParams = { + start: () => ({ + core: { + overlays, + } as any, + plugins: { + drilldowns, + }, + self: {}, + }), +}; + +test('should create', () => { + expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( + true + ); +}); + +interface CompatibilityParams { + isEdit?: boolean; + isValueClickTriggerSupported?: boolean; + isEmbeddableEnhanced?: boolean; + rootType?: string; +} + +describe('isCompatible', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + async function assertCompatibility( + { + isEdit = true, + isValueClickTriggerSupported = true, + isEmbeddableEnhanced = true, + rootType = 'dashboard', + }: CompatibilityParams, + expectedResult: boolean = true + ): Promise { + let embeddable = new MockEmbeddable( + { id: '', viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW }, + { + supportedTriggers: (isValueClickTriggerSupported ? ['VALUE_CLICK_TRIGGER'] : []) as Array< + keyof TriggerContextMapping + >, + } + ); + + embeddable.rootType = rootType; + + if (isEmbeddableEnhanced) { + embeddable = enhanceEmbeddable(embeddable); + } + + const result = await drilldownAction.isCompatible({ + embeddable, + }); + + expect(result).toBe(expectedResult); + } + + const assertNonCompatibility = (params: CompatibilityParams) => + assertCompatibility(params, false); + + test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { + await assertCompatibility({}); + }); + + test('not compatible if embeddable is not enhanced', async () => { + await assertNonCompatibility({ + isEmbeddableEnhanced: false, + }); + }); + + test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { + await assertNonCompatibility({ + isValueClickTriggerSupported: false, + }); + }); + + test('not compatible if in view mode', async () => { + await assertNonCompatibility({ + isEdit: false, + }); + }); + + test('not compatible if root embeddable is not "dashboard"', async () => { + await assertNonCompatibility({ + rootType: 'visualization', + }); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction."` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + const embeddable = enhanceEmbeddable(new MockEmbeddable({ id: '' }, {})); + + await drilldownAction.execute({ + embeddable, + }); + + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 000000000000000..81f88e563a25860 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; +import { StartDependencies } from '../../../../plugin'; +import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface OpenFlyoutAddDrilldownParams { + start: StartServicesGetter>; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 12; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + private isEmbeddableCompatible(context: EmbeddableContext) { + if (!isEnhancedEmbeddable(context.embeddable)) return false; + const supportedTriggers = context.embeddable.supportedTriggers(); + if (!supportedTriggers || !supportedTriggers.length) return false; + if (context.embeddable.getRoot().type !== 'dashboard') return false; + + /** + * Temporarily disable drilldowns for Lens as Lens embeddable does not have + * `.embeddable` field on VALUE_CLICK_TRIGGER context. + * + * @todo Remove this condition once Lens adds `.embeddable` to field to context. + */ + if (context.embeddable.type === 'lens') return false; + + return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; + } + + public async isCompatible(context: EmbeddableContext) { + const isEditMode = context.embeddable.getInput().viewMode === 'edit'; + return isEditMode && this.isEmbeddableCompatible(context); + } + + public async execute(context: EmbeddableContext) { + const { core, plugins } = this.params.start(); + const { embeddable } = context; + + if (!isEnhancedEmbeddable(embeddable)) { + throw new Error( + 'Need embeddable to be EnhancedEmbeddable to execute FlyoutCreateDrilldownAction.' + ); + } + + const handle = core.overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'create'} + dynamicActionManager={embeddable.enhancements.dynamicActions} + /> + ), + { + ownFocus: true, + 'data-test-subj': 'createDrilldownFlyout', + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts new file mode 100644 index 000000000000000..4d2db209fc96199 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, + OPEN_FLYOUT_ADD_DRILLDOWN, +} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx new file mode 100644 index 000000000000000..555acf1fca5ffa8 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsEnhancedPluginMock } from '../../../../../../advanced_ui_actions/public/mocks'; +import { EnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActionsPlugin = uiActionsEnhancedPluginMock.createPlugin(); +const uiActions = uiActionsPlugin.doStart(); + +uiActionsPlugin.setup.registerDrilldown({ + id: 'foo', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + execute: async () => {}, + getDisplayName: () => 'test', +}); + +const actionParams: FlyoutEditDrilldownParams = { + start: () => ({ + core: { + overlays, + } as any, + plugins: { + drilldowns, + }, + self: {}, + }), +}; + +test('should create', () => { + expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); +}); + +test('MenuItem exists', () => { + expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); +}); + +describe('isCompatible', () => { + function setupIsCompatible({ + isEdit = true, + isEmbeddableEnhanced = true, + }: { + isEdit?: boolean; + isEmbeddableEnhanced?: boolean; + } = {}) { + const action = new FlyoutEditDrilldownAction(actionParams); + const input = { + id: '', + viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW, + }; + const embeddable = new MockEmbeddable(input, {}); + const context = { + embeddable: (isEmbeddableEnhanced + ? enhanceEmbeddable(embeddable, uiActions) + : embeddable) as EnhancedEmbeddable, + }; + + return { + action, + context, + }; + } + + test('not compatible if no drilldowns', async () => { + const { action, context } = setupIsCompatible(); + expect(await action.isCompatible(context)).toBe(false); + }); + + test('not compatible if embeddable is not enhanced', async () => { + const { action, context } = setupIsCompatible({ isEmbeddableEnhanced: false }); + expect(await action.isCompatible(context)).toBe(false); + }); + + describe('when has at least one drilldown', () => { + test('is compatible in edit mode', async () => { + const { action, context } = setupIsCompatible(); + + await context.embeddable.enhancements.dynamicActions.createEvent( + { + config: {}, + factoryId: 'foo', + name: '', + }, + ['VALUE_CLICK_TRIGGER'] + ); + + expect(await action.isCompatible(context)).toBe(true); + }); + + test('not compatible in view mode', async () => { + const { action, context } = setupIsCompatible({ isEdit: false }); + + await context.embeddable.enhancements.dynamicActions.createEvent( + { + config: {}, + factoryId: 'foo', + name: '', + }, + ['VALUE_CLICK_TRIGGER'] + ); + + expect(await action.isCompatible(context)).toBe(false); + }); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction."` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: enhanceEmbeddable(new MockEmbeddable({ id: '' }, {})), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx new file mode 100644 index 000000000000000..a4499ba4d757d6c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + reactToUiComponent, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; +import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { txtDisplayName } from './i18n'; +import { MenuItem } from './menu_item'; +import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import { StartDependencies } from '../../../../plugin'; +import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownParams { + start: StartServicesGetter>; +} + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 10; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return txtDisplayName; + } + + public getIconType() { + return 'list'; + } + + MenuItem = reactToUiComponent(MenuItem); + + public async isCompatible({ embeddable }: EmbeddableContext) { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + if (!isEnhancedEmbeddable(embeddable)) return false; + return embeddable.enhancements.dynamicActions.state.get().events.length > 0; + } + + public async execute(context: EmbeddableContext) { + const { core, plugins } = this.params.start(); + const { embeddable } = context; + + if (!isEnhancedEmbeddable(embeddable)) { + throw new Error( + 'Need embeddable to be EnhancedEmbeddable to execute FlyoutEditDrilldownAction.' + ); + } + + const handle = core.overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={embeddable.enhancements.dynamicActions} + /> + ), + { + ownFocus: true, + 'data-test-subj': 'editDrilldownFlyout', + } + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts index ceabc6d3a9aa51d..4e2e5eb7092e455 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', +export const txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Create drilldown', + defaultMessage: 'Manage drilldowns', } ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 000000000000000..3e1b37f270708d5 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutEditDrilldownAction, + FlyoutEditDrilldownParams, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx new file mode 100644 index 000000000000000..ec3a78e97eae4a9 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, cleanup, act } from '@testing-library/react/pure'; +import { MenuItem } from './menu_item'; +import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '../../../../../../advanced_ui_actions/public'; +import { EnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; +import '@testing-library/jest-dom'; + +afterEach(cleanup); + +test('', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + + ); + + expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); + expect(queryByText('0')).not.toBeInTheDocument(); + + act(() => { + state.set({ events: [{}] }); + }); + + expect(queryByText('1')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx new file mode 100644 index 000000000000000..5a04e03e034575c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { EnhancedEmbeddableContext } from '../../../../../../embeddable_enhanced/public'; +import { txtDisplayName } from './i18n'; + +export const MenuItem: React.FC<{ context: EnhancedEmbeddableContext }> = ({ context }) => { + const { events } = useContainerState(context.embeddable.enhancements.dynamicActions.state); + const count = events.length; + + return ( + + {txtDisplayName} + {count > 0 && ( + + {count} + + )} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/actions/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts new file mode 100644 index 000000000000000..cccacf701a9ada3 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable } from '../../../../../embeddable_enhanced/public'; +import { + UiActionsEnhancedMemoryActionStorage as MemoryActionStorage, + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + AdvancedUiActionsStart, +} from '../../../../../advanced_ui_actions/public'; +import { TriggerContextMapping } from '../../../../../../../src/plugins/ui_actions/public'; +import { uiActionsEnhancedPluginMock } from '../../../../../advanced_ui_actions/public/mocks'; + +export class MockEmbeddable extends Embeddable { + public rootType = 'dashboard'; + public readonly type = 'mock'; + private readonly triggers: Array = []; + constructor( + initialInput: EmbeddableInput, + params: { supportedTriggers?: Array } + ) { + super(initialInput, {}, undefined); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array { + return this.triggers; + } + public getRoot() { + return { + type: this.rootType, + } as Embeddable; + } +} + +export const enhanceEmbeddable = ( + embeddable: E, + uiActions: AdvancedUiActionsStart = uiActionsEnhancedPluginMock.createStartContract() +): EnhancedEmbeddable => { + (embeddable as EnhancedEmbeddable).enhancements = { + dynamicActions: new DynamicActionManager({ + storage: new MemoryActionStorage(), + isCompatible: async () => true, + uiActions, + }), + }; + return embeddable as EnhancedEmbeddable; +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts new file mode 100644 index 000000000000000..0161836b2c5b97a --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { SetupDependencies, StartDependencies } from '../../plugin'; +import { CONTEXT_MENU_TRIGGER } from '../../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddableContext } from '../../../../embeddable_enhanced/public'; +import { + FlyoutCreateDrilldownAction, + FlyoutEditDrilldownAction, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; +import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; +import { createStartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; + +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: EnhancedEmbeddableContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: EnhancedEmbeddableContext; + } +} + +interface BootstrapParams { + enableDrilldowns: boolean; +} + +export class DashboardDrilldownsService { + bootstrap( + core: CoreSetup, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns( + core: CoreSetup, + { advancedUiActions: uiActions }: SetupDependencies + ) { + const start = createStartServicesGetter(core.getStartServices); + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ start }); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ start }); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + + const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ start }); + uiActions.registerDrilldown(dashboardToDashboardDrilldown); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx new file mode 100644 index 000000000000000..dc19fccf5c92ff0 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { debounce, findIndex } from 'lodash'; +import { SimpleSavedObject } from '../../../../../../../../src/core/public'; +import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; +import { txtDestinationDashboardNotFound } from './i18n'; +import { CollectConfigProps } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { Config } from '../types'; +import { Params } from '../drilldown'; + +const mergeDashboards = ( + dashboards: Array>, + selectedDashboard?: EuiComboBoxOptionOption +) => { + // if we have a selected dashboard and its not in the list, append it + if (selectedDashboard && findIndex(dashboards, { value: selectedDashboard.value }) === -1) { + return [selectedDashboard, ...dashboards]; + } + return dashboards; +}; + +const dashboardSavedObjectToMenuItem = ( + savedObject: SimpleSavedObject<{ + title: string; + }> +) => ({ + value: savedObject.id, + label: savedObject.attributes.title, +}); + +interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { + params: Params; +} + +interface CollectConfigContainerState { + dashboards: Array>; + searchString?: string; + isLoading: boolean; + selectedDashboard?: EuiComboBoxOptionOption; + error?: string; +} + +export class CollectConfigContainer extends React.Component< + DashboardDrilldownCollectConfigProps, + CollectConfigContainerState +> { + private isMounted = true; + state = { + dashboards: [], + isLoading: false, + searchString: undefined, + selectedDashboard: undefined, + error: undefined, + }; + + constructor(props: DashboardDrilldownCollectConfigProps) { + super(props); + this.debouncedLoadDashboards = debounce(this.loadDashboards.bind(this), 500); + } + + componentDidMount() { + this.loadSelectedDashboard(); + this.loadDashboards(); + } + + componentWillUnmount() { + this.isMounted = false; + } + + render() { + const { config, onConfig } = this.props; + const { dashboards, selectedDashboard, isLoading, error } = this.state; + + return ( + { + onConfig({ ...config, dashboardId }); + if (this.state.error) { + this.setState({ error: undefined }); + } + }} + onSearchChange={this.debouncedLoadDashboards} + onCurrentFiltersToggle={() => + onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + onKeepRangeToggle={() => + onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + ); + } + + private async loadSelectedDashboard() { + const { + config, + params: { start }, + } = this.props; + if (!config.dashboardId) return; + const savedObject = await start().core.savedObjects.client.get<{ title: string }>( + 'dashboard', + config.dashboardId + ); + + if (!this.isMounted) return; + + // handle case when destination dashboard no longer exists + if (savedObject.error?.statusCode === 404) { + this.setState({ + error: txtDestinationDashboardNotFound(config.dashboardId), + }); + this.props.onConfig({ ...config, dashboardId: undefined }); + return; + } + + if (savedObject.error) { + this.setState({ + error: savedObject.error.message, + }); + this.props.onConfig({ ...config, dashboardId: undefined }); + return; + } + + this.setState({ selectedDashboard: dashboardSavedObjectToMenuItem(savedObject) }); + } + + private readonly debouncedLoadDashboards: (searchString?: string) => void; + private async loadDashboards(searchString?: string) { + this.setState({ searchString, isLoading: true }); + const savedObjectsClient = this.props.params.start().core.savedObjects.client; + const { savedObjects } = await savedObjectsClient.find<{ title: string }>({ + type: 'dashboard', + search: searchString ? `${searchString}*` : undefined, + searchFields: ['title^3', 'description'], + defaultSearchOperator: 'AND', + perPage: 100, + }); + + // bail out if this response is no longer needed + if (!this.isMounted) return; + if (searchString !== this.state.searchString) return; + + const dashboardList = savedObjects.map(dashboardSavedObjectToMenuItem); + + this.setState({ dashboards: dashboardList, isLoading: false }); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx new file mode 100644 index 000000000000000..f3a966a73509c2b --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; + +export const dashboards = [ + { value: 'dashboard1', label: 'Dashboard 1' }, + { value: 'dashboard2', label: 'Dashboard 2' }, + { value: 'dashboard3', label: 'Dashboard 3' }, +]; + +const InteractiveDemo: React.FC = () => { + const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); + const [currentFilters, setCurrentFilters] = React.useState(false); + const [keepRange, setKeepRange] = React.useState(false); + + return ( + setActiveDashboardId(id)} + onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} + onKeepRangeToggle={() => setKeepRange(old => !old)} + onSearchChange={() => {}} + isLoading={false} + /> + ); +}; + +storiesOf( + 'services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config', + module +) + .add('default', () => ( + console.log('onDashboardSelect', e)} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('with switches', () => ( + console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx new file mode 100644 index 000000000000000..edeb7de48d9acfc --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Need to wait for https://github.com/elastic/eui/pull/3173/ +// to unit test this component +// basic interaction is covered in end-to-end tests +test.todo(''); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx new file mode 100644 index 000000000000000..a41a5fb718219d2 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSwitch, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { + txtChooseDestinationDashboard, + txtUseCurrentFilters, + txtUseCurrentDateRange, +} from './i18n'; + +export interface DashboardDrilldownConfigProps { + activeDashboardId?: string; + dashboards: Array>; + currentFilters?: boolean; + keepRange?: boolean; + onDashboardSelect: (dashboardId: string) => void; + onCurrentFiltersToggle?: () => void; + onKeepRangeToggle?: () => void; + onSearchChange: (searchString: string) => void; + isLoading: boolean; + error?: string; +} + +export const DashboardDrilldownConfig: React.FC = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, + onSearchChange, + isLoading, + error, +}) => { + const selectedTitle = dashboards.find(item => item.value === activeDashboardId)?.label || ''; + + return ( + <> + + + async + selectedOptions={ + activeDashboardId ? [{ label: selectedTitle, value: activeDashboardId }] : [] + } + options={dashboards} + onChange={([{ value = '' } = { value: '' }]) => onDashboardSelect(value)} + onSearchChange={onSearchChange} + isLoading={isLoading} + singleSelection={{ asPlainText: true }} + fullWidth + data-test-subj={'dashboardDrilldownSelectDashboard'} + isInvalid={!!error} + /> + + {!!onCurrentFiltersToggle && ( + + + + )} + {!!onKeepRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts new file mode 100644 index 000000000000000..a37f2bfa01bd49b --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/i18n.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChooseDestinationDashboard = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', + { + defaultMessage: 'Choose destination dashboard', + } +); + +export const txtUseCurrentFilters = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentFilters', + { + defaultMessage: 'Use filters and query from origin dashboard', + } +); + +export const txtUseCurrentDateRange = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.useCurrentDateRange', + { + defaultMessage: 'Use date range from origin dashboard', + } +); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts new file mode 100644 index 000000000000000..b9a64a3cc17e6e7 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/dashboard_drilldown_config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts new file mode 100644 index 000000000000000..6f6f7412f6b538f --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/i18n.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtDestinationDashboardNotFound = (dashboardId?: string) => + i18n.translate('xpack.dashboard.drilldown.errorDestinationDashboardIsMissing', { + defaultMessage: + "Destination dashboard ('{dashboardId}') no longer exists. Choose another dashboard.", + values: { + dashboardId, + }, + }); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts new file mode 100644 index 000000000000000..c34290528d914b4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CollectConfigContainer } from './collect_config_container'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts new file mode 100644 index 000000000000000..e2a530b156da5ca --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx new file mode 100644 index 000000000000000..18ee95cb57b3bcf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -0,0 +1,363 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DashboardToDashboardDrilldown } from './drilldown'; +import { UrlGeneratorContract } from '../../../../../../../src/plugins/share/public'; +import { savedObjectsServiceMock } from '../../../../../../../src/core/public/mocks'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { ActionContext, Config } from './types'; +import { + Filter, + FilterStateStore, + Query, + RangeFilter, + TimeRange, +} from '../../../../../../../src/plugins/data/common'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; + +// convenient to use real implementation here. +import { createDirectAccessDashboardLinkGenerator } from '../../../../../../../src/plugins/dashboard/public/url_generator'; +import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; +import { + RangeSelectTriggerContext, + ValueClickTriggerContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_objects/public'; +import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public/core'; +import { StartDependencies } from '../../../plugin'; + +describe('.isConfigValid()', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + + test('returns false for invalid config with missing dashboard id', () => { + expect( + drilldown.isConfigValid({ + dashboardId: '', + useCurrentDateRange: false, + useCurrentFilters: false, + }) + ).toBe(false); + }); + + test('returns true for valid config', () => { + expect( + drilldown.isConfigValid({ + dashboardId: 'id', + useCurrentDateRange: false, + useCurrentFilters: false, + }) + ).toBe(true); + }); +}); + +test('config component exist', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + expect(drilldown.CollectConfig).toEqual(expect.any(Function)); +}); + +test('initial config: switches are ON', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + const { useCurrentDateRange, useCurrentFilters } = drilldown.createConfig(); + expect(useCurrentDateRange).toBe(true); + expect(useCurrentFilters).toBe(true); +}); + +test('getHref is defined', () => { + const drilldown = new DashboardToDashboardDrilldown({} as any); + expect(drilldown.getHref).toBeDefined(); +}); + +describe('.execute() & getHref', () => { + /** + * A convenience test setup helper + * Beware: `dataPluginMock.createStartContract().actions` and extracting filters from event is mocked! + * The url generation is not mocked and uses real implementation + * So this tests are mostly focused on making sure the filters returned from `dataPluginMock.createStartContract().actions` helpers + * end up in resulting navigation path + */ + async function setupTestBed( + config: Partial, + embeddableInput: { filters?: Filter[]; timeRange?: TimeRange; query?: Query }, + filtersFromEvent: Filter[], + useRangeEvent = false + ) { + const navigateToApp = jest.fn(); + const getUrlForApp = jest.fn((app, opt) => `${app}/${opt.path}`); + const dataPluginActions = dataPluginMock.createStartContract().actions; + const savedObjectsClient = savedObjectsServiceMock.createStartContract().client; + + const drilldown = new DashboardToDashboardDrilldown({ + start: ((() => ({ + core: { + application: { + navigateToApp, + getUrlForApp, + }, + savedObjects: { + client: savedObjectsClient, + }, + }, + plugins: { + advancedUiActions: {}, + data: { + actions: dataPluginActions, + }, + share: { + urlGenerators: { + getUrlGenerator: () => + createDirectAccessDashboardLinkGenerator(() => + Promise.resolve({ + appBasePath: 'test', + useHashedUrl: false, + savedDashboardLoader: ({} as unknown) as SavedObjectLoader, + }) + ) as UrlGeneratorContract, + }, + }, + }, + self: {}, + })) as unknown) as StartServicesGetter< + Pick + >, + }); + const selectRangeFiltersSpy = jest + .spyOn(dataPluginActions, 'createFiltersFromRangeSelectAction') + .mockImplementation(() => Promise.resolve(filtersFromEvent)); + const valueClickFiltersSpy = jest + .spyOn(dataPluginActions, 'createFiltersFromValueClickAction') + .mockImplementation(() => Promise.resolve(filtersFromEvent)); + + const completeConfig: Config = { + dashboardId: 'id', + useCurrentFilters: false, + useCurrentDateRange: false, + ...config, + }; + + const context = ({ + data: useRangeEvent + ? ({ range: {} } as RangeSelectTriggerContext['data']) + : ({ data: [] } as ValueClickTriggerContext['data']), + timeFieldName: 'order_date', + embeddable: { + getInput: () => ({ + filters: [], + timeRange: { from: 'now-15m', to: 'now' }, + query: { query: 'test', language: 'kuery' }, + ...embeddableInput, + }), + }, + } as unknown) as ActionContext; + + await drilldown.execute(completeConfig, context); + + if (useRangeEvent) { + expect(selectRangeFiltersSpy).toBeCalledTimes(1); + expect(valueClickFiltersSpy).toBeCalledTimes(0); + } else { + expect(selectRangeFiltersSpy).toBeCalledTimes(0); + expect(valueClickFiltersSpy).toBeCalledTimes(1); + } + + expect(navigateToApp).toBeCalledTimes(1); + expect(navigateToApp.mock.calls[0][0]).toBe('kibana'); + + const executeNavigatedPath = navigateToApp.mock.calls[0][1]?.path; + const href = await drilldown.getHref(completeConfig, context); + + expect(href.includes(executeNavigatedPath)).toBe(true); + + return { + href, + }; + } + + test('navigates to correct dashboard', async () => { + const testDashboardId = 'dashboardId'; + const { href } = await setupTestBed( + { + dashboardId: testDashboardId, + }, + {}, + [], + false + ); + + expect(href).toEqual(expect.stringContaining(`dashboard/${testDashboardId}`)); + }); + + test('query is removed if filters are disabled', async () => { + const queryString = 'querystring'; + const queryLanguage = 'kuery'; + const { href } = await setupTestBed( + { + useCurrentFilters: false, + }, + { + query: { query: queryString, language: queryLanguage }, + }, + [] + ); + + expect(href).toEqual(expect.not.stringContaining(queryString)); + expect(href).toEqual(expect.not.stringContaining(queryLanguage)); + }); + + test('navigates with query if filters are enabled', async () => { + const queryString = 'querystring'; + const queryLanguage = 'kuery'; + const { href } = await setupTestBed( + { + useCurrentFilters: true, + }, + { + query: { query: queryString, language: queryLanguage }, + }, + [] + ); + + expect(href).toEqual(expect.stringContaining(queryString)); + expect(href).toEqual(expect.stringContaining(queryLanguage)); + }); + + test('when user chooses to keep current filters, current filters are set on destination dashboard', async () => { + const existingAppFilterKey = 'appExistingFilter'; + const existingGlobalFilterKey = 'existingGlobalFilter'; + const newAppliedFilterKey = 'newAppliedFilter'; + + const { href } = await setupTestBed( + { + useCurrentFilters: true, + }, + { + filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], + }, + [getFilter(false, newAppliedFilterKey)] + ); + + expect(href).toEqual(expect.stringContaining(existingAppFilterKey)); + expect(href).toEqual(expect.stringContaining(existingGlobalFilterKey)); + expect(href).toEqual(expect.stringContaining(newAppliedFilterKey)); + }); + + test('when user chooses to remove current filters, current app filters are remove on destination dashboard', async () => { + const existingAppFilterKey = 'appExistingFilter'; + const existingGlobalFilterKey = 'existingGlobalFilter'; + const newAppliedFilterKey = 'newAppliedFilter'; + + const { href } = await setupTestBed( + { + useCurrentFilters: false, + }, + { + filters: [getFilter(false, existingAppFilterKey), getFilter(true, existingGlobalFilterKey)], + }, + [getFilter(false, newAppliedFilterKey)] + ); + + expect(href).not.toEqual(expect.stringContaining(existingAppFilterKey)); + expect(href).toEqual(expect.stringContaining(existingGlobalFilterKey)); + expect(href).toEqual(expect.stringContaining(newAppliedFilterKey)); + }); + + test('when user chooses to keep current time range, current time range is passed in url', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: true, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [] + ); + + expect(href).toEqual(expect.stringContaining('now-300m')); + }); + + test('when user chooses to not keep current time range, no current time range is passed in url', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: false, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [], + false + ); + + expect(href).not.toEqual(expect.stringContaining('now-300m')); + }); + + test('if range filter contains date, then it is passed as time', async () => { + const { href } = await setupTestBed( + { + useCurrentDateRange: true, + }, + { + timeRange: { + from: 'now-300m', + to: 'now', + }, + }, + [getMockTimeRangeFilter()], + true + ); + + expect(href).not.toEqual(expect.stringContaining('now-300m')); + expect(href).toEqual(expect.stringContaining('2020-03-23')); + }); +}); + +function getFilter(isPinned: boolean, queryKey: string): Filter { + return { + $state: { + store: isPinned ? esFilters.FilterStateStore.GLOBAL_STATE : FilterStateStore.APP_STATE, + }, + meta: { + index: 'logstash-*', + disabled: false, + negate: false, + alias: null, + }, + query: { + match: { + [queryKey]: 'any', + }, + }, + }; +} + +function getMockTimeRangeFilter(): RangeFilter { + return { + meta: { + index: 'logstash-*', + params: { + gte: '2020-03-23T13:10:29.665Z', + lt: '2020-03-23T13:10:36.736Z', + format: 'strict_date_optional_time', + }, + type: 'range', + key: 'order_date', + disabled: false, + negate: false, + alias: null, + }, + range: { + order_date: { + gte: '2020-03-23T13:10:29.665Z', + lt: '2020-03-23T13:10:36.736Z', + format: 'strict_date_optional_time', + }, + }, + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx new file mode 100644 index 000000000000000..848e77384f7f045 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { DASHBOARD_APP_URL_GENERATOR } from '../../../../../../../src/plugins/dashboard/public'; +import { ActionContext, Config } from './types'; +import { CollectConfigContainer } from './components'; +import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../../advanced_ui_actions/public'; +import { txtGoToDashboard } from './i18n'; +import { esFilters } from '../../../../../../../src/plugins/data/public'; +import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; +import { + isRangeSelectTriggerContext, + isValueClickTriggerContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public'; +import { StartDependencies } from '../../../plugin'; + +export interface Params { + start: StartServicesGetter>; +} + +export class DashboardToDashboardDrilldown + implements Drilldown> { + constructor(protected readonly params: Params) {} + + public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly getHref = async ( + config: Config, + context: ActionContext + ): Promise => { + const dashboardPath = await this.getDestinationUrl(config, context); + const dashboardHash = dashboardPath.split('#')[1]; + + return this.params.start().core.application.getUrlForApp('kibana', { + path: `#${dashboardHash}`, + }); + }; + + public readonly execute = async ( + config: Config, + context: ActionContext + ) => { + const dashboardPath = await this.getDestinationUrl(config, context); + const dashboardHash = dashboardPath.split('#')[1]; + + await this.params.start().core.application.navigateToApp('kibana', { + path: `#${dashboardHash}`, + }); + }; + + private getDestinationUrl = async ( + config: Config, + context: ActionContext + ): Promise => { + const { + createFiltersFromRangeSelectAction, + createFiltersFromValueClickAction, + } = this.params.start().plugins.data.actions; + const { + timeRange: currentTimeRange, + query, + filters: currentFilters, + } = context.embeddable!.getInput(); + + // if useCurrentDashboardFilters enabled, then preserve all the filters (pinned and unpinned) + // otherwise preserve only pinned + const existingFilters = + (config.useCurrentFilters + ? currentFilters + : currentFilters?.filter(f => esFilters.isFilterPinned(f))) ?? []; + + // if useCurrentDashboardDataRange is enabled, then preserve current time range + // if undefined is passed, then destination dashboard will figure out time range itself + // for brush event this time range would be overwritten + let timeRange = config.useCurrentDateRange ? currentTimeRange : undefined; + let filtersFromEvent = await (async () => { + try { + if (isRangeSelectTriggerContext(context)) + return await createFiltersFromRangeSelectAction(context.data); + if (isValueClickTriggerContext(context)) + return await createFiltersFromValueClickAction(context.data); + + // eslint-disable-next-line no-console + console.warn( + ` + DashboardToDashboard drilldown: can't extract filters from action. + Is it not supported action?`, + context + ); + + return []; + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + ` + DashboardToDashboard drilldown: error extracting filters from action. + Continuing without applying filters from event`, + e + ); + return []; + } + })(); + + if (context.timeFieldName) { + const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( + context.timeFieldName, + filtersFromEvent + ); + filtersFromEvent = restOfFilters; + if (timeRangeFilter) { + timeRange = esFilters.convertRangeFilterToTimeRangeString(timeRangeFilter); + } + } + + const { plugins } = this.params.start(); + + return plugins.share.urlGenerators.getUrlGenerator(DASHBOARD_APP_URL_GENERATOR).createUrl({ + dashboardId: config.dashboardId, + query: config.useCurrentFilters ? query : undefined, + timeRange, + filters: [...existingFilters, ...filtersFromEvent], + }); + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts new file mode 100644 index 000000000000000..98b746bafd24af9 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts new file mode 100644 index 000000000000000..914f34980a27221 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + DashboardToDashboardDrilldown, + Params as DashboardToDashboardDrilldownParams, +} from './drilldown'; +export { + ActionContext as DashboardToDashboardActionContext, + Config as DashboardToDashboardConfig, +} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts new file mode 100644 index 000000000000000..1fbff0a7269e262 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ValueClickTriggerContext, + RangeSelectTriggerContext, + IEmbeddable, +} from '../../../../../../../src/plugins/embeddable/public'; + +export type ActionContext = + | ValueClickTriggerContext + | RangeSelectTriggerContext; + +export interface Config { + dashboardId?: string; + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts new file mode 100644 index 000000000000000..7be8f1c65da126c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/drilldowns/public/service/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts similarity index 86% rename from x-pack/plugins/drilldowns/public/service/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/index.ts index 44472b18a531729..8cc3e129065311d 100644 --- a/x-pack/plugins/drilldowns/public/service/index.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_service'; +export * from './drilldowns'; diff --git a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js new file mode 100644 index 000000000000000..5d95c56c31e3b13 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'dashboard_enhanced', + storyGlobs: [join(__dirname, '..', 'public', '**', '*.story.tsx')], +}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index 4dba07b5a7be37b..678c054aa322c4c 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,9 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "configPath": ["xpack", "drilldowns"], - "requiredPlugins": [ - "uiActions", - "embeddable" - ] + "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"], + "configPath": ["xpack", "drilldowns"] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx deleted file mode 100644 index 4834cc80813742c..000000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface FlyoutCreateDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit'; - } - - public async execute(context: FlyoutCreateDrilldownActionContext) { - const overlays = await this.params.overlays(); - const handle = overlays.openFlyout( - toMountPoint( handle.close()} />) - ); - } -} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index f109da94fcaca76..000000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { EuiNotificationBadge } from '@elastic/eui'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { - toMountPoint, - reactToUiComponent, -} from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FormCreateDrilldown } from '../../components/form_create_drilldown'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return displayName; - } - - public getIconType() { - return 'list'; - } - - private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { - return ( - <> - {displayName}{' '} - - {drilldrownCount} - - - ); - }; - - MenuItem = reactToUiComponent(this.ReactComp); - - public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; - } - - public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { - const overlays = await this.params.overlays(); - overlays.openFlyout(toMountPoint()); - } -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx new file mode 100644 index 000000000000000..16b4d3a25d9e5d3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { mockDynamicActionManager } from './test_data'; + +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage: new Storage(new StubBrowserStorage()), + notifications: { + toasts: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + } as any, + }, +}); + +storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx new file mode 100644 index 000000000000000..6749b41e81fc707 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { mockDynamicActionManager } from './test_data'; +import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; +import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { NotificationsStart } from 'kibana/public'; +import { toastDrilldownsCRUDError } from './i18n'; + +const storage = new Storage(new StubBrowserStorage()); +const notifications = coreMock.createStart().notifications; +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage, + notifications, +}); + +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +beforeEach(() => { + storage.clear(); + (notifications.toasts as jest.Mocked).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + // no drilldowns in the list + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); + + fireEvent.click(screen.getByText(/Create new/i)); + + let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + expect(createHeading).toBeVisible(); + expect(screen.getByLabelText(/Back/i)).toBeVisible(); + + expect(createButton).toBeDisabled(); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + expect(screen.getByText(name)).toBeVisible(); + const editButton = screen.getByText(/edit/i); + fireEvent.click(editButton); + + expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); + // check that wizard is prefilled with current drilldown values + expect(screen.getByLabelText(/name/i)).toHaveValue(name); + expect(screen.getByLabelText(/url/i)).toHaveValue(URL); + + // input new drilldown name + const newName = 'New drilldown name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: newName }, + }); + fireEvent.click(screen.getByText(/save/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => screen.getByText(newName)); + + // delete drilldown from edit view + fireEvent.click(screen.getByText(/edit/i)); + fireEvent.click(screen.getByText(/delete/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Can delete multiple drilldowns', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + const createDrilldown = async () => { + const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) + ); + }; + + await createDrilldown(); + await createDrilldown(); + await createDrilldown(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(checkbox => fireEvent.click(checkbox)); + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(/Delete \(3\)/i)); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Create only mode', async () => { + const onClose = jest.fn(); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(onClose).toBeCalled(); + expect(await mockDynamicActionManager.state.get().events.length).toBe(1); +}); + +test.todo("Error when can't fetch drilldown list"); + +test("Error when can't save drilldown changes", async () => { + const error = new Error('Oops'); + jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { + throw error; + }); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + ); +}); + +test('Should show drilldown welcome message. Should be able to dismiss it', async () => { + let screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); + fireEvent.click(screen.getByText(/hide/i)); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); + cleanup(); + + screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); +}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx new file mode 100644 index 000000000000000..0d4a67e325e4dd6 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + AdvancedUiActionsStart, + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + UiActionsEnhancedSerializedAction, + UiActionsEnhancedSerializedEvent, +} from '../../../../advanced_ui_actions/public'; +import { NotificationsStart } from '../../../../../../src/core/public'; +import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; +import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/public'; +import { DrilldownListItem } from '../list_manage_drilldowns'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; + +interface ConnectedFlyoutManageDrilldownsProps { + placeContext: Context; + dynamicActionManager: DynamicActionManager; + viewMode?: 'create' | 'manage'; + onClose?: () => void; +} + +/** + * Represent current state (route) of FlyoutManageDrilldowns + */ +enum Routes { + Manage = 'manage', + Create = 'create', + Edit = 'edit', +} + +export function createFlyoutManageDrilldowns({ + advancedUiActions, + storage, + notifications, +}: { + advancedUiActions: AdvancedUiActionsStart; + storage: IStorageWrapper; + notifications: NotificationsStart; +}) { + // fine to assume this is static, + // because all action factories should be registered in setup phase + const allActionFactories = advancedUiActions.getActionFactories(); + const allActionFactoriesById = allActionFactories.reduce((acc, next) => { + acc[next.id] = next; + return acc; + }, {} as Record); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array = React.useMemo( + () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], + [] + ); + + const factoryContext: object = React.useMemo( + () => ({ + placeContext: props.placeContext, + triggers: selectedTriggers, + }), + [props.placeContext, selectedTriggers] + ); + + const actionFactories = useCompatibleActionFactoriesForCurrentContext( + allActionFactories, + factoryContext + ); + + const [route, setRoute] = useState( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState(null); + + const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); + + const { + drilldowns, + createDrilldown, + editDrilldown, + deleteDrilldown, + } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + + /** + * isCompatible promise is not yet resolved. + * Skip rendering until it is resolved + */ + if (!actionFactories) return null; + /** + * Drilldowns are not fetched yet or error happened during fetching + * In case of error user is notified with toast + */ + if (!drilldowns) return null; + + /** + * Needed for edit mode to prefill wizard fields with data from current edited drilldown + */ + function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { + if (route !== Routes.Edit) return undefined; + if (!currentEditId) return undefined; + const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); + if (!drilldownToEdit) return undefined; + + return { + actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], + actionConfig: drilldownToEdit.action.config as object, + name: drilldownToEdit.action.name, + }; + } + + /** + * Maps drilldown to list item view model + */ + function mapToDrilldownToDrilldownListItem( + drilldown: UiActionsEnhancedSerializedEvent + ): DrilldownListItem { + const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + return { + id: drilldown.eventId, + drilldownName: drilldown.action.name, + actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(factoryContext), + }; + } + + switch (route) { + case Routes.Create: + case Routes.Edit: + return ( + setRoute(Routes.Manage)} + onSubmit={({ actionConfig, actionFactory, name }) => { + if (route === Routes.Create) { + createDrilldown( + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } else { + editDrilldown( + currentEditId!, + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } + + if (isCreateOnly) { + if (props.onClose) { + props.onClose(); + } + } else { + setRoute(Routes.Manage); + } + + setCurrentEditId(null); + }} + onDelete={() => { + deleteDrilldown(currentEditId!); + setRoute(Routes.Manage); + setCurrentEditId(null); + }} + actionFactoryContext={factoryContext} + initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + /> + ); + + case Routes.Manage: + default: + return ( + { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext( + actionFactories: Array>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array> + >(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map(factory => factory.isCompatible(context)) + ); + if (canceled) return; + setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories]); + + return compatibleActionFactories; +} + +function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hidWelcomeMessage, + () => { + if (hidWelcomeMessage) return; + setHidWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} + +function useDrilldownsStateManager( + actionManager: DynamicActionManager, + notifications: NotificationsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsEnhancedSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownCreated.title, + text: toastDrilldownCreated.text(action.name), + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: UiActionsEnhancedSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownEdited.title, + text: toastDrilldownEdited.text(action.name), + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + notifications.toasts.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title, + text: toastDrilldownsDeleted.text(drilldownIds.length), + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts new file mode 100644 index 000000000000000..31384860786efe1 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const toastDrilldownCreated = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown created', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { + defaultMessage: 'You created "{drilldownName}". Save dashboard before testing.', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownEdited = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown edited', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { + defaultMessage: 'You edited "{drilldownName}". Save dashboard before testing.', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'You deleted a drilldown.', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: 'Drilldowns deleted', + } + ), + text: (n: number) => + i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'You deleted {n} drilldowns', + values: { + n, + }, + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); + +export const toastDrilldownsFetchError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', + { + defaultMessage: 'Error fetching drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts new file mode 100644 index 000000000000000..f084a3e563c23d4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts new file mode 100644 index 000000000000000..47a04222286cbfe --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + UiActionsEnhancedDynamicActionManagerState as DynamicActionManagerState, + UiActionsEnhancedSerializedAction, +} from '../../../../advanced_ui_actions/public'; +import { TriggerContextMapping } from '../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; + +class MockDynamicActionManager implements PublicMethodsOf { + public readonly state = createStateContainer({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsEnhancedSerializedAction, + triggers: Array + ) { + const event = { + action, + triggers, + eventId: uuid(), + }; + const state = this.state.get(); + this.state.set({ + ...state, + events: [...state.events, event], + }); + } + + async deleteEvents(eventIds: string[]) { + const state = this.state.get(); + let events = state.events; + + eventIds.forEach(id => { + events = events.filter(e => e.eventId !== id); + }); + + this.state.set({ + ...state, + events, + }); + } + + async updateEvent( + eventId: string, + action: UiActionsEnhancedSerializedAction, + triggers: Array + ) { + const state = this.state.get(); + const events = state.events; + const idx = events.findIndex(e => e.eventId === eventId); + const event = { + eventId, + action, + triggers, + }; + + this.state.set({ + ...state, + events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], + }); + } + + async deleteEvent() { + throw new Error('not implemented'); + } + + async start() {} + async stop() {} +} + +export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index 7a9e19342f27ce1..c4a4630397f1c21 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,6 +8,16 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -storiesOf('components/DrilldownHelloBar', module).add('default', () => { - return ; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 1ef714f7b86e2e7..48e17dadc810f09 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiText, + EuiLink, + EuiSpacer, + EuiButtonEmpty, + EuiIcon, +} from '@elastic/eui'; +import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; + onHideClick?: () => void; } -/** - * @todo https://github.com/elastic/kibana/issues/55311 - */ -export const DrilldownHelloBar: React.FC = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldownsWelcomeMessage'; + +export const DrilldownHelloBar: React.FC = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( -
-

- Drilldowns provide the ability to define a new behavior when interacting with a panel. You - can add multiple options or simply override the default filtering behavior. -

- View docs -
+ + +
+ +
+
+ + + {txtHelpText} + + {docsLink && ( + <> + + {txtViewDocsLinkLabel} + + )} + + + + {txtHideHelpButtonLabel} + + + + } + /> ); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts new file mode 100644 index 000000000000000..63dc95dabc0fbff --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtHelpText = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + { + defaultMessage: + 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', + } +); + +export const txtViewDocsLinkLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + { + defaultMessage: 'View docs', + } +); + +export const txtHideHelpButtonLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + { + defaultMessage: 'Hide', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx deleted file mode 100644 index 3748fc666c81c5e..000000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -// eslint-disable-next-line -export interface DrilldownPickerProps {} - -export const DrilldownPicker: React.FC = () => { - return ( - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx deleted file mode 100644 index 4f024b7d9cd6a28..000000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutCreateDrilldown } from '.'; - -storiesOf('components/FlyoutCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index b45ac9197c7e062..000000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormCreateDrilldown } from '../form_create_drilldown'; -import { FlyoutFrame } from '../flyout_frame'; -import { txtCreateDrilldown } from './i18n'; -import { FlyoutCreateDrilldownActionContext } from '../../actions'; - -export interface FlyoutCreateDrilldownProps { - context: FlyoutCreateDrilldownActionContext; - onClose?: () => void; -} - -export const FlyoutCreateDrilldown: React.FC = ({ - context, - onClose, -}) => { - const footer = ( - {}} fill> - {txtCreateDrilldown} - - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx new file mode 100644 index 000000000000000..152cd393b9d3eca --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutDrilldownWizard } from '.'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; + +storiesOf('components/FlyoutDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('open in flyout - create', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + + ); + }) + .add('open in flyout - edit', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + {}}> + {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx new file mode 100644 index 000000000000000..8541aae06ff0c7a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormDrilldownWizard } from '../form_drilldown_wizard'; +import { FlyoutFrame } from '../flyout_frame'; +import { + txtCreateDrilldownButtonLabel, + txtCreateDrilldownTitle, + txtDeleteDrilldownButtonLabel, + txtEditDrilldownButtonLabel, + txtEditDrilldownTitle, +} from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; + +export interface DrilldownWizardConfig { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps { + drilldownActionFactories: Array>; + + onSubmit?: (drilldownWizardConfig: Required) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps) { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + data-test-subj={'drilldownWizardSubmit'} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + + ); + + return ( + } + > + { + setWizardConfig({ + ...wizardConfig, + name: newName, + }); + }} + actionConfig={wizardConfig.actionConfig} + onActionConfigChange={newActionConfig => { + setWizardConfig({ + ...wizardConfig, + actionConfig: newActionConfig, + }); + }} + currentActionFactory={wizardConfig.actionFactory} + onActionFactoryChange={actionFactory => { + if (!actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } else { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionFactory.createConfig(), + }); + } + }} + actionFactories={drilldownActionFactories} + actionFactoryContext={actionFactoryContext!} + /> + {mode === 'edit' && ( + <> + + + {txtDeleteDrilldownButtonLabel} + + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts new file mode 100644 index 000000000000000..a4a2754a444ab36 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + { + defaultMessage: 'Create Drilldown', + } +); + +export const txtEditDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + { + defaultMessage: 'Edit Drilldown', + } +); + +export const txtCreateDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + { + defaultMessage: 'Create drilldown', + } +); + +export const txtEditDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const txtDeleteDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + { + defaultMessage: 'Delete drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts new file mode 100644 index 000000000000000..96ed23bf112c9a4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index 2715637f6392fe6..cb223db556f5608 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,6 +21,13 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) + .add('with onBack', () => { + return ( + console.log('onClose')} title={'Title'}> + test + + ); + }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index b5fb52fcf5c18a8..0a3989487745f92 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,9 +6,11 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { FlyoutFrame } from '.'; +afterEach(cleanup); + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index 2945cfd739482d9..b55cbd88d0dc068 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,13 +13,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiButtonIcon, } from '@elastic/eui'; -import { txtClose } from './i18n'; +import { txtClose, txtBack } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; + banner?: React.ReactNode; onClose?: () => void; + onBack?: () => void; } /** @@ -30,11 +33,31 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( -

{title}

+ + {onBack && ( + +
+ +
+
+ )} + {title && ( + +

{title}

+
+ )} +
); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 257d7d36dbee191..23af89ebf9bc7ef 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', }); + +export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { + defaultMessage: 'Back', +}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx new file mode 100644 index 000000000000000..0529f0451b16a08 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; + +storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx new file mode 100644 index 000000000000000..a44a7ccccb4dcea --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FlyoutFrame } from '../flyout_frame'; +import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; +import { txtManageDrilldowns } from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; + +export interface FlyoutListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + onClose?: () => void; + onCreate?: () => void; + onEdit?: (drilldownId: string) => void; + onDelete?: (drilldownIds: string[]) => void; + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; +} + +export function FlyoutListManageDrilldowns({ + drilldowns, + onClose = () => {}, + onCreate, + onDelete, + onEdit, + showWelcomeMessage = true, + onWelcomeHideClick, +}: FlyoutListManageDrilldownsProps) { + return ( + } + > + + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts similarity index 52% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx rename to x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts index 5627a5d6f4522b1..0dd4e37d4dddd75 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; +import { i18n } from '@kbn/i18n'; -storiesOf('components/DrilldownPicker', module).add('default', () => { - return ; -}); +export const txtManageDrilldowns = i18n.translate( + 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + { + defaultMessage: 'Manage Drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts new file mode 100644 index 000000000000000..f8c9d224fb29233 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx deleted file mode 100644 index e7e1d67473e8c77..000000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FormCreateDrilldown } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx deleted file mode 100644 index 4422de604092bbc..000000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; -import { DrilldownPicker } from '../drilldown_picker'; - -const noop = () => {}; - -export interface FormCreateDrilldownProps { - name?: string; - onNameChange?: (name: string) => void; -} - -export const FormCreateDrilldown: React.FC = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const triggerPicker =
Trigger Picker will be here
; - const actionPicker = ( - - - - ); - - return ( - <> - - {nameFragment} - {triggerPicker} - {actionPicker} - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx new file mode 100644 index 000000000000000..2fc35eb6b5298fd --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { FormDrilldownWizard } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ( + <> + {' '} +
name: {name}
+ + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 60% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 6691966e47e6478..d9c53ae6f737a44 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -6,41 +6,39 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; -describe('', () => { +afterEach(cleanup); + +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} />, div); + render( {}} actionFactoryContext={{}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; expect(input?.value).toBe(''); }); - test('can set name input field value', () => { + test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -48,7 +46,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx new file mode 100644 index 000000000000000..93b3710bf6cc666 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + ActionWizard, +} from '../../../../advanced_ui_actions/public'; + +const noopFn = () => {}; + +export interface FormDrilldownWizardProps { + name?: string; + onNameChange?: (name: string) => void; + + currentActionFactory?: ActionFactory; + onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + actionFactoryContext: object; + + actionConfig?: object; + onActionConfigChange?: (config: object) => void; + + actionFactories?: ActionFactory[]; +} + +export const FormDrilldownWizard: React.FC = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="drilldownNameInput" + /> + + ); + + const actionWizard = ( + 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + + ); + + return ( + <> + + {nameFragment} + + {actionWizard} + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts index 4c0e287935edd73..e9b19ab0afa9735 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name of drilldown', + defaultMessage: 'Name', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Drilldown action', + defaultMessage: 'Action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx index c2c5a7e435b3918..4aea824de00d7ff 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_create_drilldown'; +export * from './form_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts new file mode 100644 index 000000000000000..fbc7c9dcfb4a181 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + { + defaultMessage: 'Create new', + } +); + +export const txtEditDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + { + defaultMessage: 'Edit', + } +); + +export const txtDeleteDrilldowns = (count: number) => + i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + }); + +export const txtSelectDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + { + defaultMessage: 'Select this drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx new file mode 100644 index 000000000000000..82b6ce27af6d4cc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx new file mode 100644 index 000000000000000..eafe50bab201671 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { ListManageDrilldowns } from './list_manage_drilldowns'; + +storiesOf('components/ListManageDrilldowns', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx new file mode 100644 index 000000000000000..4a4d67b08b1d379 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { + DrilldownListItem, + ListManageDrilldowns, + TEST_SUBJ_DRILLDOWN_ITEM, +} from './list_manage_drilldowns'; + +// TODO: for some reason global cleanup from RTL doesn't work +// afterEach is not available for it globally during setup +afterEach(cleanup); + +const drilldowns: DrilldownListItem[] = [ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, +]; + +test('Render list of drilldowns', () => { + const screen = render(); + expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); +}); + +test('Emit onEdit() when clicking on edit drilldown', () => { + const fn = jest.fn(); + const screen = render(); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons).toHaveLength(drilldowns.length); + fireEvent.click(editButtons[1]); + expect(fn).toBeCalledWith(drilldowns[1].id); +}); + +test('Emit onCreate() when clicking on create drilldown', () => { + const fn = jest.fn(); + const screen = render(); + fireEvent.click(screen.getByText('Create new')); + expect(fn).toBeCalled(); +}); + +test('Delete button is not visible when non is selected', () => { + const fn = jest.fn(); + const screen = render(); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Delete \(2\)/i)); + + expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); +}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx new file mode 100644 index 000000000000000..ab51c0a829ed351 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + txtCreateDrilldown, + txtDeleteDrilldowns, + txtEditDrilldown, + txtSelectDrilldown, +} from './i18n'; + +export interface DrilldownListItem { + id: string; + actionName: string; + drilldownName: string; + icon?: string; +} + +export interface ListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + + onEdit?: (id: string) => void; + onCreate?: () => void; + onDelete?: (ids: string[]) => void; +} + +const noop = () => {}; + +export const TEST_SUBJ_DRILLDOWN_ITEM = 'listManageDrilldownsItem'; + +export function ListManageDrilldowns({ + drilldowns, + onEdit = noop, + onCreate = noop, + onDelete = noop, +}: ListManageDrilldownsProps) { + const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); + + const columns: Array> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + 'data-test-subj': 'drilldownListItemName', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + + {drilldown.icon && ( + + + + )} + + {drilldown.actionName} + + + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + onEdit(drilldown.id)}> + {txtEditDrilldown} + + ), + }, + ]; + + return ( + <> + { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + + {selectedDrilldowns.length === 0 ? ( + onCreate()}> + {txtCreateDrilldown} + + ) : ( + onDelete(selectedDrilldowns)} + data-test-subj={'listManageDeleteDrilldowns'} + > + {txtDeleteDrilldowns(selectedDrilldowns.length)} + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a1223546206..f976356822dce77 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,10 +7,10 @@ import { DrilldownsPlugin } from './plugin'; export { - DrilldownsSetupContract, - DrilldownsSetupDependencies, - DrilldownsStartContract, - DrilldownsStartDependencies, + SetupContract as DrilldownsSetup, + SetupDependencies as DrilldownsSetupDependencies, + StartContract as DrilldownsStart, + StartDependencies as DrilldownsStartDependencies, } from './plugin'; export function plugin() { diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index bfade1674072ac4..18816243a3572d9 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; +import { DrilldownsSetup, DrilldownsStart } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,12 +17,14 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = {}; + const startContract: Start = { + FlyoutManageDrilldowns: jest.fn(), + }; return startContract; }; -export const bfetchPluginMock = { +export const drilldownsPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index b89172541b91e3b..0108e04df9c99a0 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,52 +6,41 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DrilldownService } from './service'; -import { - FlyoutCreateDrilldownActionContext, - FlyoutEditDrilldownActionContext, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; - -export interface DrilldownsSetupDependencies { +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; + +export interface SetupDependencies { uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } -export interface DrilldownsStartDependencies { +export interface StartDependencies { uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } -export type DrilldownsSetupContract = Pick; - // eslint-disable-next-line -export interface DrilldownsStartContract {} +export interface SetupContract {} -declare module '../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; - } +export interface StartContract { + FlyoutManageDrilldowns: ReturnType; } export class DrilldownsPlugin - implements - Plugin< - DrilldownsSetupContract, - DrilldownsStartContract, - DrilldownsSetupDependencies, - DrilldownsStartDependencies - > { - private readonly service = new DrilldownService(); - - public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { - this.service.bootstrap(core, plugins); - - return this.service; + implements Plugin { + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + return {}; } - public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { - return {}; + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return { + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + advancedUiActions: plugins.advancedUiActions, + storage: new Storage(localStorage), + notifications: core.notifications, + }), + }; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts deleted file mode 100644 index 7745c30b4e3358d..000000000000000 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup } from 'src/core/public'; -// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; -import { DrilldownsSetupDependencies } from '../plugin'; - -export class DrilldownService { - bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutCreateDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutEditDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - } - - /** - * Convenience method to register a drilldown. (It should set-up all the - * necessary triggers and actions.) - */ - registerDrilldown = (): void => { - throw new Error('not implemented'); - }; -} diff --git a/x-pack/plugins/embeddable_enhanced/README.md b/x-pack/plugins/embeddable_enhanced/README.md new file mode 100644 index 000000000000000..a0be90731fdb022 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of `embeddable` plugin diff --git a/x-pack/plugins/embeddable_enhanced/kibana.json b/x-pack/plugins/embeddable_enhanced/kibana.json new file mode 100644 index 000000000000000..780a1d5d89870ef --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/kibana.json @@ -0,0 +1,7 @@ +{ + "id": "embeddableEnhanced", + "version": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["embeddable", "advancedUiActions"] +} diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/index.ts b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts new file mode 100644 index 000000000000000..b47abd48fd2691f --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './panel_notifications_action'; diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts new file mode 100644 index 000000000000000..839379387e09431 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PanelNotificationsAction } from './panel_notifications_action'; +import { EnhancedEmbeddableContext } from '../types'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; + +const createContext = (events: unknown[] = [], isEditMode = false): EnhancedEmbeddableContext => + ({ + embeddable: { + getInput: () => + ({ + viewMode: isEditMode ? ViewMode.EDIT : ViewMode.VIEW, + } as unknown), + enhancements: { + dynamicActions: { + state: { + get: () => + ({ + events, + } as unknown), + }, + }, + }, + }, + } as EnhancedEmbeddableContext); + +describe('PanelNotificationsAction', () => { + describe('getDisplayName', () => { + test('returns "0" if embeddable has no events', async () => { + const context = createContext(); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayName(context); + expect(name).toBe('0'); + }); + + test('returns "2" if embeddable has two events', async () => { + const context = createContext([{}, {}]); + const action = new PanelNotificationsAction(); + + const name = await action.getDisplayName(context); + expect(name).toBe('2'); + }); + }); + + describe('isCompatible', () => { + test('returns false if not in "edit" mode', async () => { + const context = createContext([{}]); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(false); + }); + + test('returns true when in "edit" mode', async () => { + const context = createContext([{}], true); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(true); + }); + + test('returns false when no embeddable has no events', async () => { + const context = createContext([], true); + const action = new PanelNotificationsAction(); + + const result = await action.isCompatible(context); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts new file mode 100644 index 000000000000000..19e0ac2a5a6d8e3 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/actions/panel_notifications_action.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddableContext, EnhancedEmbeddable } from '../types'; + +export const ACTION_PANEL_NOTIFICATIONS = 'ACTION_PANEL_NOTIFICATIONS'; + +/** + * This action renders in "edit" mode number of events (dynamic action) a panel + * has attached to it. + */ +export class PanelNotificationsAction implements ActionDefinition { + public readonly id = ACTION_PANEL_NOTIFICATIONS; + + private getEventCount(embeddable: EnhancedEmbeddable): number { + return embeddable.enhancements.dynamicActions.state.get().events.length; + } + + public readonly getDisplayName = ({ embeddable }: EnhancedEmbeddableContext) => { + return String(this.getEventCount(embeddable)); + }; + + public readonly isCompatible = async ({ embeddable }: EnhancedEmbeddableContext) => { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + return this.getEventCount(embeddable) > 0; + }; + + public readonly execute = async () => {}; +} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts similarity index 72% rename from src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts rename to x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts index ddd84b05443455d..f8b3a9dfb92d07c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts @@ -1,29 +1,18 @@ /* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. */ -import { Embeddable } from './embeddable'; -import { EmbeddableInput } from './i_embeddable'; -import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; -import { of } from '../../../../kibana_utils/public'; +import { Embeddable, ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { + EmbeddableActionStorage, + EmbeddableWithDynamicActionsInput, +} from './embeddable_action_storage'; +import { UiActionsEnhancedSerializedEvent } from '../../../advanced_ui_actions/public'; +import { of } from '../../../../../src/plugins/kibana_utils/public'; -class TestEmbeddable extends Embeddable { +class TestEmbeddable extends Embeddable { public readonly type = 'test'; constructor() { super({ id: 'test', viewMode: ViewMode.VIEW }, {}); @@ -42,62 +31,79 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([]); await storage.create(event); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event]); }); + test('does not merge .getInput() into .updateInput()', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const spy = jest.spyOn(embeddable, 'updateInput'); + + await storage.create(event); + + expect(spy.mock.calls[0][0].id).toBe(undefined); + expect(spy.mock.calls[0][0].viewMode).toBe(undefined); + }); + test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([]); await storage.create(event1); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1]); await storage.create(event2); await storage.create(event3); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1, event2, event3]); }); test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -122,16 +128,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, @@ -140,7 +146,7 @@ describe('EmbeddableActionStorage', () => { await storage.create(event1); await storage.update(event2); - const events = embeddable.getInput().events || []; + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events).toEqual([event2]); }); @@ -148,30 +154,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event22: SerializedEvent = { + const event22: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'baz', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -181,17 +187,17 @@ describe('EmbeddableActionStorage', () => { await storage.create(event2); await storage.create(event3); - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([event1, event2, event3]); await storage.update(event22); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1, event22, event3]); await storage.update(event2); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1, event2, event3]); }); @@ -199,9 +205,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -217,14 +223,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -249,16 +255,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; await storage.create(event); await storage.remove(event.eventId); - const events = embeddable.getInput().events || []; + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events).toEqual([]); }); @@ -266,23 +272,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -292,22 +298,22 @@ describe('EmbeddableActionStorage', () => { await storage.create(event2); await storage.create(event3); - const events1 = embeddable.getInput().events || []; + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events1).toEqual([event1, event2, event3]); await storage.remove(event2.eventId); - const events2 = embeddable.getInput().events || []; + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events2).toEqual([event1, event3]); await storage.remove(event3.eventId); - const events3 = embeddable.getInput().events || []; + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events3).toEqual([event1]); await storage.remove(event1.eventId); - const events4 = embeddable.getInput().events || []; + const events4 = embeddable.getInput().enhancements?.dynamicActions?.events || []; expect(events4).toEqual([]); }); @@ -327,9 +333,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -355,9 +361,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -383,9 +389,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -402,19 +408,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -458,7 +464,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -466,7 +472,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -502,15 +508,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsEnhancedSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts new file mode 100644 index 000000000000000..dcb44323f6d1117 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + UiActionsEnhancedAbstractActionStorage as AbstractActionStorage, + UiActionsEnhancedSerializedEvent as SerializedEvent, +} from '../../../advanced_ui_actions/public'; +import { + EmbeddableInput, + EmbeddableOutput, + IEmbeddable, +} from '../../../../../src/plugins/embeddable/public'; + +export interface EmbeddableWithDynamicActionsInput extends EmbeddableInput { + enhancements?: { + dynamicActions?: { + events: SerializedEvent[]; + }; + }; +} + +export type EmbeddableWithDynamicActions< + I extends EmbeddableWithDynamicActionsInput = EmbeddableWithDynamicActionsInput, + O extends EmbeddableOutput = EmbeddableOutput +> = IEmbeddable; + +export class EmbeddableActionStorage extends AbstractActionStorage { + constructor(private readonly embbeddable: EmbeddableWithDynamicActions) { + super(); + } + + private put(input: EmbeddableWithDynamicActionsInput, events: SerializedEvent[]) { + this.embbeddable.updateInput({ + enhancements: { + ...(input.enhancements || {}), + dynamicActions: { + ...(input.enhancements?.dynamicActions || {}), + events, + }, + }, + }); + } + + public async create(event: SerializedEvent) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + const exists = !!events.find(({ eventId }) => eventId === event.eventId); + + if (exists) { + throw new Error( + `[EEXIST]: Event with [eventId = ${event.eventId}] already exists on ` + + `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` + ); + } + + this.put(input, [...events, event]); + } + + public async update(event: SerializedEvent) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + const index = events.findIndex(({ eventId }) => eventId === event.eventId); + + if (index === -1) { + throw new Error( + `[ENOENT]: Event with [eventId = ${event.eventId}] could not be ` + + `updated as it does not exist in ` + + `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` + ); + } + + this.put(input, [...events.slice(0, index), event, ...events.slice(index + 1)]); + } + + public async remove(eventId: string) { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + const index = events.findIndex(event => eventId === event.eventId); + + if (index === -1) { + throw new Error( + `[ENOENT]: Event with [eventId = ${eventId}] could not be ` + + `removed as it does not exist in ` + + `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` + ); + } + + this.put(input, [...events.slice(0, index), ...events.slice(index + 1)]); + } + + public async read(eventId: string): Promise { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + const event = events.find(ev => eventId === ev.eventId); + + if (!event) { + throw new Error( + `[ENOENT]: Event with [eventId = ${eventId}] could not be found in ` + + `[embeddable.id = ${input.id}, embeddable.title = ${input.title}].` + ); + } + + return event; + } + + public async list(): Promise { + const input = this.embbeddable.getInput(); + const events = input.enhancements?.dynamicActions?.events || []; + return events; + } +} diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts new file mode 100644 index 000000000000000..fabbc60a13f67a8 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './is_enhanced_embeddable'; +export * from './embeddable_action_storage'; diff --git a/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts new file mode 100644 index 000000000000000..f29430dc6a3deb1 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/is_enhanced_embeddable.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEmbeddable } from '../../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable } from '../types'; + +export const isEnhancedEmbeddable = ( + maybeEnhancedEmbeddable: E +): maybeEnhancedEmbeddable is EnhancedEmbeddable => + typeof (maybeEnhancedEmbeddable as EnhancedEmbeddable) + ?.enhancements?.dynamicActions === 'object'; diff --git a/x-pack/plugins/embeddable_enhanced/public/index.ts b/x-pack/plugins/embeddable_enhanced/public/index.ts new file mode 100644 index 000000000000000..059acf96448207d --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { EmbeddableEnhancedPlugin } from './plugin'; + +export { + SetupContract as EmbeddableEnhancedSetupContract, + SetupDependencies as EmbeddableEnhancedSetupDependencies, + StartContract as EmbeddableEnhancedStartContract, + StartDependencies as EmbeddableEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new EmbeddableEnhancedPlugin(context); +} + +export { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types'; +export { isEnhancedEmbeddable } from './embeddables'; diff --git a/x-pack/plugins/embeddable_enhanced/public/mocks.ts b/x-pack/plugins/embeddable_enhanced/public/mocks.ts new file mode 100644 index 000000000000000..d048d1248b6ff2f --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmbeddableEnhancedSetupContract, EmbeddableEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const embeddableEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts new file mode 100644 index 000000000000000..d48c4f9e860cc25 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { SavedObjectAttributes } from 'kibana/public'; +import { + EmbeddableFactory, + EmbeddableFactoryDefinition, + EmbeddableInput, + EmbeddableOutput, + EmbeddableSetup, + EmbeddableStart, + IEmbeddable, + defaultEmbeddableFactoryProvider, + EmbeddableContext, + PANEL_NOTIFICATION_TRIGGER, +} from '../../../../src/plugins/embeddable/public'; +import { EnhancedEmbeddable, EnhancedEmbeddableContext } from './types'; +import { + EmbeddableActionStorage, + EmbeddableWithDynamicActions, +} from './embeddables/embeddable_action_storage'; +import { + UiActionsEnhancedDynamicActionManager as DynamicActionManager, + AdvancedUiActionsSetup, + AdvancedUiActionsStart, +} from '../../advanced_ui_actions/public'; +import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions'; + +declare module '../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [ACTION_PANEL_NOTIFICATIONS]: EnhancedEmbeddableContext; + } +} + +export interface SetupDependencies { + embeddable: EmbeddableSetup; + advancedUiActions: AdvancedUiActionsSetup; +} + +export interface StartDependencies { + embeddable: EmbeddableStart; + advancedUiActions: AdvancedUiActionsStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class EmbeddableEnhancedPlugin + implements Plugin { + constructor(protected readonly context: PluginInitializerContext) {} + + private uiActions?: StartDependencies['advancedUiActions']; + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.setCustomEmbeddableFactoryProvider(plugins); + + const panelNotificationAction = new PanelNotificationsAction(); + plugins.advancedUiActions.registerAction(panelNotificationAction); + plugins.advancedUiActions.attachAction(PANEL_NOTIFICATION_TRIGGER, panelNotificationAction.id); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + this.uiActions = plugins.advancedUiActions; + + return {}; + } + + public stop() {} + + private setCustomEmbeddableFactoryProvider(plugins: SetupDependencies) { + plugins.embeddable.setCustomEmbeddableFactoryProvider( + < + I extends EmbeddableInput = EmbeddableInput, + O extends EmbeddableOutput = EmbeddableOutput, + E extends IEmbeddable = IEmbeddable, + T extends SavedObjectAttributes = SavedObjectAttributes + >( + def: EmbeddableFactoryDefinition + ): EmbeddableFactory => { + const factory: EmbeddableFactory = defaultEmbeddableFactoryProvider( + def + ); + return { + ...factory, + create: async (...args) => { + const embeddable = await factory.create(...args); + if (!embeddable) return embeddable; + return this.enhanceEmbeddableWithDynamicActions(embeddable); + }, + createFromSavedObject: async (...args) => { + const embeddable = await factory.createFromSavedObject(...args); + if (!embeddable) return embeddable; + return this.enhanceEmbeddableWithDynamicActions(embeddable); + }, + }; + } + ); + } + + private enhanceEmbeddableWithDynamicActions( + embeddable: E + ): EnhancedEmbeddable { + const enhancedEmbeddable = embeddable as EnhancedEmbeddable; + + const storage = new EmbeddableActionStorage(embeddable as EmbeddableWithDynamicActions); + const dynamicActions = new DynamicActionManager({ + isCompatible: async (context: unknown) => { + if (!(context as EmbeddableContext)?.embeddable) { + // eslint-disable-next-line no-console + console.warn('For drilldowns to work action context should contain .embeddable field.'); + return false; + } + + return (context as EmbeddableContext).embeddable.runtimeId === embeddable.runtimeId; + }, + storage, + uiActions: this.uiActions!, + }); + + dynamicActions.start().catch(error => { + /* eslint-disable */ + console.log('Failed to start embeddable dynamic actions', embeddable); + console.error(error); + /* eslint-enable */ + }); + + const stop = () => { + dynamicActions.stop().catch(error => { + /* eslint-disable */ + console.log('Failed to stop embeddable dynamic actions', embeddable); + console.error(error); + /* eslint-enable */ + }); + }; + + embeddable.getInput$().subscribe({ + next: () => { + storage.reload$.next(); + }, + error: stop, + complete: stop, + }); + + enhancedEmbeddable.enhancements = { + ...enhancedEmbeddable.enhancements, + dynamicActions, + }; + + return enhancedEmbeddable; + } +} diff --git a/x-pack/plugins/embeddable_enhanced/public/types.ts b/x-pack/plugins/embeddable_enhanced/public/types.ts new file mode 100644 index 000000000000000..924605be332b218 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { UiActionsEnhancedDynamicActionManager as DynamicActionManager } from '../../advanced_ui_actions/public'; + +export type EnhancedEmbeddable = E & { + enhancements: { + /** + * Default implementation of dynamic action manager for embeddables. + */ + dynamicActions: DynamicActionManager; + }; +}; + +export interface EnhancedEmbeddableContext { + embeddable: EnhancedEmbeddable; +} diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts b/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts new file mode 100644 index 000000000000000..c9f98ac5fcdea28 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/validation/datasets.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const LOG_ANALYSIS_VALIDATE_DATASETS_PATH = + '/api/infra/log_analysis/validation/log_entry_datasets'; + +/** + * Request types + */ +export const validateLogEntryDatasetsRequestPayloadRT = rt.type({ + data: rt.type({ + indices: rt.array(rt.string), + timestampField: rt.string, + startTime: rt.number, + endTime: rt.number, + }), +}); + +export type ValidateLogEntryDatasetsRequestPayload = rt.TypeOf< + typeof validateLogEntryDatasetsRequestPayloadRT +>; + +/** + * Response types + * */ +const logEntryDatasetsEntryRT = rt.strict({ + indexName: rt.string, + datasets: rt.array(rt.string), +}); + +export const validateLogEntryDatasetsResponsePayloadRT = rt.type({ + data: rt.type({ + datasets: rt.array(logEntryDatasetsEntryRT), + }), +}); + +export type ValidateLogEntryDatasetsResponsePayload = rt.TypeOf< + typeof validateLogEntryDatasetsResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/validation/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/validation/index.ts index f23ef7ee7c30254..5f02f5598e6a478 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/validation/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/validation/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './datasets'; export * from './log_entry_rate_indices'; diff --git a/x-pack/plugins/infra/common/log_analysis/job_parameters.ts b/x-pack/plugins/infra/common/log_analysis/job_parameters.ts index 94643e21f1ea6f2..7e10e45bbae4da1 100644 --- a/x-pack/plugins/infra/common/log_analysis/job_parameters.ts +++ b/x-pack/plugins/infra/common/log_analysis/job_parameters.ts @@ -21,17 +21,73 @@ export const getJobId = (spaceId: string, sourceId: string, jobType: string) => export const getDatafeedId = (spaceId: string, sourceId: string, jobType: string) => `datafeed-${getJobId(spaceId, sourceId, jobType)}`; -export const jobSourceConfigurationRT = rt.type({ +export const datasetFilterRT = rt.union([ + rt.strict({ + type: rt.literal('includeAll'), + }), + rt.strict({ + type: rt.literal('includeSome'), + datasets: rt.array(rt.string), + }), +]); + +export type DatasetFilter = rt.TypeOf; + +export const jobSourceConfigurationRT = rt.partial({ indexPattern: rt.string, timestampField: rt.string, bucketSpan: rt.number, + datasetFilter: datasetFilterRT, }); export type JobSourceConfiguration = rt.TypeOf; export const jobCustomSettingsRT = rt.partial({ job_revision: rt.number, - logs_source_config: rt.partial(jobSourceConfigurationRT.props), + logs_source_config: jobSourceConfigurationRT, }); export type JobCustomSettings = rt.TypeOf; + +export const combineDatasetFilters = ( + firstFilter: DatasetFilter, + secondFilter: DatasetFilter +): DatasetFilter => { + if (firstFilter.type === 'includeAll' && secondFilter.type === 'includeAll') { + return { + type: 'includeAll', + }; + } + + const includedDatasets = new Set([ + ...(firstFilter.type === 'includeSome' ? firstFilter.datasets : []), + ...(secondFilter.type === 'includeSome' ? secondFilter.datasets : []), + ]); + + return { + type: 'includeSome', + datasets: [...includedDatasets], + }; +}; + +export const filterDatasetFilter = ( + datasetFilter: DatasetFilter, + predicate: (dataset: string) => boolean +): DatasetFilter => { + if (datasetFilter.type === 'includeAll') { + return datasetFilter; + } else { + const newDatasets = datasetFilter.datasets.filter(predicate); + + if (newDatasets.length > 0) { + return { + type: 'includeSome', + datasets: newDatasets, + }; + } else { + return { + type: 'includeAll', + }; + } + } +}; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx index b18c2e5b8d69c71..37cea9314cfe889 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/alert_flyout.tsx @@ -24,7 +24,9 @@ export const AlertFlyout = (props: Props) => { {triggersActionsUI && ( = ({ comparator, value, updateCount, values: { value }, }); + const documentCountSuffix = i18n.translate('xpack.infra.logs.alertFlyout.documentCountSuffix', { + defaultMessage: '{value, plural, one {occurs} other {occur}}', + values: { value }, + }); + return ( @@ -122,6 +127,10 @@ export const DocumentCount: React.FC = ({ comparator, value, updateCount, + + + + ); }; diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index 3aed0db53bf2ca7..8bdffbeb36f3a54 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useEffect, useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty, EuiLoadingSpinner, EuiSpacer, EuiButton, EuiCallOut } from '@elastic/eui'; +import { useMount } from 'react-use'; import { FormattedMessage } from '@kbn/i18n/react'; import { ForLastExpression, @@ -13,7 +15,8 @@ import { } from '../../../../../../triggers_actions_ui/public/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; -import { useSource } from '../../../../containers/source'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../../triggers_actions_ui/public/application/context/alerts_context'; import { LogDocumentCountAlertParams, Comparator, @@ -21,6 +24,8 @@ import { } from '../../../../../common/alerting/logs/types'; import { DocumentCount } from './document_count'; import { Criteria } from './criteria'; +import { useSourceId } from '../../../../containers/source_id'; +import { LogSourceProvider, useLogSourceContext } from '../../../../containers/logs/log_source'; export interface ExpressionCriteria { field?: string; @@ -28,11 +33,16 @@ export interface ExpressionCriteria { value?: string | number; } +interface LogsContextMeta { + isInternal?: boolean; +} + interface Props { errors: IErrorObject; alertParams: Partial; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; + alertsContext: AlertsContextValue; } const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; @@ -48,32 +58,92 @@ const DEFAULT_EXPRESSION = { }; export const ExpressionEditor: React.FC = props => { + const isInternal = props.alertsContext.metadata?.isInternal; + const [sourceId] = useSourceId(); + + return ( + <> + {isInternal ? ( + + + + ) : ( + + + + + + )} + + ); +}; + +export const SourceStatusWrapper: React.FC = props => { + const { + initialize, + isLoadingSourceStatus, + isUninitialized, + hasFailedLoadingSourceStatus, + loadSourceStatus, + } = useLogSourceContext(); + const { children } = props; + + useMount(() => { + initialize(); + }); + + return ( + <> + {isLoadingSourceStatus || isUninitialized ? ( +
+ + + +
+ ) : hasFailedLoadingSourceStatus ? ( + + + {i18n.translate('xpack.infra.logs.alertFlyout.sourceStatusErrorTryAgain', { + defaultMessage: 'Try again', + })} + + + ) : ( + children + )} + + ); +}; + +export const Editor: React.FC = props => { const { setAlertParams, alertParams, errors } = props; - const { createDerivedIndexPattern } = useSource({ sourceId: 'default' }); const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); const [hasSetDefaults, setHasSetDefaults] = useState(false); - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('logs'), [ - createDerivedIndexPattern, - ]); + const { sourceStatus } = useLogSourceContext(); + + useMount(() => { + for (const [key, value] of Object.entries(DEFAULT_EXPRESSION)) { + setAlertParams(key, value); + setHasSetDefaults(true); + } + }); const supportedFields = useMemo(() => { - if (derivedIndexPattern?.fields) { - return derivedIndexPattern.fields.filter(field => { + if (sourceStatus?.logIndexFields) { + return sourceStatus.logIndexFields.filter(field => { return (field.type === 'string' || field.type === 'number') && field.searchable; }); } else { return []; } - }, [derivedIndexPattern]); - - // Set the default expression (disables exhaustive-deps as we only want to run this once on mount) - useEffect(() => { - for (const [key, value] of Object.entries(DEFAULT_EXPRESSION)) { - setAlertParams(key, value); - setHasSetDefaults(true); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [sourceStatus]); const updateCount = useCallback( countParams => { @@ -126,8 +196,6 @@ export const ExpressionEditor: React.FC = props => { [alertParams, setAlertParams] ); - // Wait until field info has loaded - if (supportedFields.length === 0) return null; // Wait until the alert param defaults have been set if (!hasSetDefaults) return null; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx index 649858f657bfe93..06dbf5315b83ac4 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/analysis_setup_indices_form.tsx @@ -4,56 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiCode, EuiDescribedFormGroup, EuiFormRow, EuiCheckbox, EuiToolTip } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useCallback, useMemo } from 'react'; - +import React, { useCallback } from 'react'; import { LoadingOverlayWrapper } from '../../../loading_overlay_wrapper'; -import { ValidatedIndex, ValidationIndicesUIError } from './validation'; +import { IndexSetupRow } from './index_setup_row'; +import { AvailableIndex } from './validation'; export const AnalysisSetupIndicesForm: React.FunctionComponent<{ disabled?: boolean; - indices: ValidatedIndex[]; + indices: AvailableIndex[]; isValidating: boolean; - onChangeSelectedIndices: (selectedIndices: ValidatedIndex[]) => void; + onChangeSelectedIndices: (selectedIndices: AvailableIndex[]) => void; valid: boolean; }> = ({ disabled = false, indices, isValidating, onChangeSelectedIndices, valid }) => { - const handleCheckboxChange = useCallback( - (event: React.ChangeEvent) => { + const changeIsIndexSelected = useCallback( + (indexName: string, isSelected: boolean) => { onChangeSelectedIndices( indices.map(index => { - const checkbox = event.currentTarget; - return index.name === checkbox.id ? { ...index, isSelected: checkbox.checked } : index; + return index.name === indexName ? { ...index, isSelected } : index; }) ); }, [indices, onChangeSelectedIndices] ); - const choices = useMemo( - () => - indices.map(index => { - const checkbox = ( - {index.name}} - onChange={handleCheckboxChange} - checked={index.validity === 'valid' && index.isSelected} - disabled={disabled || index.validity === 'invalid'} - /> - ); - - return index.validity === 'valid' ? ( - checkbox - ) : ( -
- {checkbox} -
- ); - }), - [disabled, handleCheckboxChange, indices] + const changeDatasetFilter = useCallback( + (indexName: string, datasetFilter) => { + onChangeSelectedIndices( + indices.map(index => { + return index.name === indexName ? { ...index, datasetFilter } : index; + }) + ); + }, + [indices, onChangeSelectedIndices] ); return ( @@ -69,13 +54,23 @@ export const AnalysisSetupIndicesForm: React.FunctionComponent<{ description={ } > - <>{choices} + <> + {indices.map(index => ( + + ))} + @@ -85,51 +80,3 @@ export const AnalysisSetupIndicesForm: React.FunctionComponent<{ const indicesSelectionLabel = i18n.translate('xpack.infra.analysisSetup.indicesSelectionLabel', { defaultMessage: 'Indices', }); - -const formatValidationError = (errors: ValidationIndicesUIError[]): React.ReactNode => { - return errors.map(error => { - switch (error.error) { - case 'INDEX_NOT_FOUND': - return ( -

- {error.index} }} - /> -

- ); - - case 'FIELD_NOT_FOUND': - return ( -

- {error.index}, - field: {error.field}, - }} - /> -

- ); - - case 'FIELD_NOT_VALID': - return ( -

- {error.index}, - field: {error.field}, - }} - /> -

- ); - - default: - return ''; - } - }); -}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_dataset_filter.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_dataset_filter.tsx new file mode 100644 index 000000000000000..b37c68f837876c8 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_dataset_filter.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiSelectableOption, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useCallback, useMemo } from 'react'; +import { DatasetFilter } from '../../../../../common/log_analysis'; +import { useVisibilityState } from '../../../../utils/use_visibility_state'; + +export const IndexSetupDatasetFilter: React.FC<{ + availableDatasets: string[]; + datasetFilter: DatasetFilter; + isDisabled?: boolean; + onChangeDatasetFilter: (datasetFilter: DatasetFilter) => void; +}> = ({ availableDatasets, datasetFilter, isDisabled, onChangeDatasetFilter }) => { + const { isVisible, hide, show } = useVisibilityState(false); + + const changeDatasetFilter = useCallback( + (options: EuiSelectableOption[]) => { + const selectedDatasets = options + .filter(({ checked }) => checked === 'on') + .map(({ label }) => label); + + onChangeDatasetFilter( + selectedDatasets.length === 0 + ? { type: 'includeAll' } + : { type: 'includeSome', datasets: selectedDatasets } + ); + }, + [onChangeDatasetFilter] + ); + + const selectableOptions: EuiSelectableOption[] = useMemo( + () => + availableDatasets.map(datasetName => ({ + label: datasetName, + checked: + datasetFilter.type === 'includeSome' && datasetFilter.datasets.includes(datasetName) + ? 'on' + : undefined, + })), + [availableDatasets, datasetFilter] + ); + + const datasetFilterButton = ( + + + + ); + + return ( + + + + {(list, search) => ( +
+ {search} + {list} +
+ )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_row.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_row.tsx new file mode 100644 index 000000000000000..18dc2e5aa9bd13a --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/index_setup_row.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCheckbox, EuiCode, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiToolTip } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useCallback } from 'react'; +import { DatasetFilter } from '../../../../../common/log_analysis'; +import { IndexSetupDatasetFilter } from './index_setup_dataset_filter'; +import { AvailableIndex, ValidationIndicesUIError } from './validation'; + +export const IndexSetupRow: React.FC<{ + index: AvailableIndex; + isDisabled: boolean; + onChangeDatasetFilter: (indexName: string, datasetFilter: DatasetFilter) => void; + onChangeIsSelected: (indexName: string, isSelected: boolean) => void; +}> = ({ index, isDisabled, onChangeDatasetFilter, onChangeIsSelected }) => { + const changeIsSelected = useCallback( + (event: React.ChangeEvent) => { + onChangeIsSelected(index.name, event.currentTarget.checked); + }, + [index.name, onChangeIsSelected] + ); + + const changeDatasetFilter = useCallback( + (datasetFilter: DatasetFilter) => onChangeDatasetFilter(index.name, datasetFilter), + [index.name, onChangeDatasetFilter] + ); + + const isSelected = index.validity === 'valid' && index.isSelected; + + return ( + + + {index.name}} + onChange={changeIsSelected} + checked={isSelected} + disabled={isDisabled || index.validity === 'invalid'} + /> + + + {index.validity === 'invalid' ? ( + + + + ) : index.validity === 'valid' ? ( + + ) : null} + + + ); +}; + +const formatValidationError = (errors: ValidationIndicesUIError[]): React.ReactNode => { + return errors.map(error => { + switch (error.error) { + case 'INDEX_NOT_FOUND': + return ( +

+ {error.index} }} + /> +

+ ); + + case 'FIELD_NOT_FOUND': + return ( +

+ {error.index}, + field: {error.field}, + }} + /> +

+ ); + + case 'FIELD_NOT_VALID': + return ( +

+ {error.index}, + field: {error.field}, + }} + /> +

+ ); + + default: + return ''; + } + }); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx index 4ec895dfed4bc5c..85aa7ce5132486f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx @@ -13,7 +13,7 @@ import React, { useMemo } from 'react'; import { SetupStatus } from '../../../../../common/log_analysis'; import { AnalysisSetupIndicesForm } from './analysis_setup_indices_form'; import { AnalysisSetupTimerangeForm } from './analysis_setup_timerange_form'; -import { ValidatedIndex, ValidationIndicesUIError } from './validation'; +import { AvailableIndex, ValidationIndicesUIError } from './validation'; interface InitialConfigurationStepProps { setStartTime: (startTime: number | undefined) => void; @@ -21,9 +21,9 @@ interface InitialConfigurationStepProps { startTime: number | undefined; endTime: number | undefined; isValidating: boolean; - validatedIndices: ValidatedIndex[]; + validatedIndices: AvailableIndex[]; setupStatus: SetupStatus; - setValidatedIndices: (selectedIndices: ValidatedIndex[]) => void; + setValidatedIndices: (selectedIndices: AvailableIndex[]) => void; validationErrors?: ValidationIndicesUIError[]; } diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/validation.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/validation.tsx index 8b733f66ef4a83e..d69e544aeab1881 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/validation.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/validation.tsx @@ -5,22 +5,35 @@ */ import { ValidationIndicesError } from '../../../../../common/http_api'; +import { DatasetFilter } from '../../../../../common/log_analysis'; + +export { ValidationIndicesError }; export type ValidationIndicesUIError = | ValidationIndicesError | { error: 'NETWORK_ERROR' } | { error: 'TOO_FEW_SELECTED_INDICES' }; -interface ValidIndex { +interface ValidAvailableIndex { validity: 'valid'; name: string; isSelected: boolean; + availableDatasets: string[]; + datasetFilter: DatasetFilter; } -interface InvalidIndex { +interface InvalidAvailableIndex { validity: 'invalid'; name: string; errors: ValidationIndicesError[]; } -export type ValidatedIndex = ValidIndex | InvalidIndex; +interface UnvalidatedAvailableIndex { + validity: 'unknown'; + name: string; +} + +export type AvailableIndex = + | ValidAvailableIndex + | InvalidAvailableIndex + | UnvalidatedAvailableIndex; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts index b1265b389917e4f..7c8d63374924c36 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/ml_setup_module_api.ts @@ -21,7 +21,8 @@ export const callSetupMlModuleAPI = async ( sourceId: string, indexPattern: string, jobOverrides: SetupMlModuleJobOverrides[] = [], - datafeedOverrides: SetupMlModuleDatafeedOverrides[] = [] + datafeedOverrides: SetupMlModuleDatafeedOverrides[] = [], + query?: object ) => { const response = await npStart.http.fetch(`/api/ml/modules/setup/${moduleId}`, { method: 'POST', @@ -34,6 +35,7 @@ export const callSetupMlModuleAPI = async ( startDatafeed: true, jobOverrides, datafeedOverrides, + query, }) ), }); @@ -60,13 +62,20 @@ const setupMlModuleDatafeedOverridesRT = rt.object; export type SetupMlModuleDatafeedOverrides = rt.TypeOf; -const setupMlModuleRequestParamsRT = rt.type({ - indexPatternName: rt.string, - prefix: rt.string, - startDatafeed: rt.boolean, - jobOverrides: rt.array(setupMlModuleJobOverridesRT), - datafeedOverrides: rt.array(setupMlModuleDatafeedOverridesRT), -}); +const setupMlModuleRequestParamsRT = rt.intersection([ + rt.strict({ + indexPatternName: rt.string, + prefix: rt.string, + startDatafeed: rt.boolean, + jobOverrides: rt.array(setupMlModuleJobOverridesRT), + datafeedOverrides: rt.array(setupMlModuleDatafeedOverridesRT), + }), + rt.exact( + rt.partial({ + query: rt.object, + }) + ), +]); const setupMlModuleRequestPayloadRT = rt.intersection([ setupMlModuleTimeParamsRT, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts new file mode 100644 index 000000000000000..6c9d5e439d359e9 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/api/validate_datasets.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + LOG_ANALYSIS_VALIDATE_DATASETS_PATH, + validateLogEntryDatasetsRequestPayloadRT, + validateLogEntryDatasetsResponsePayloadRT, +} from '../../../../../common/http_api'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; +import { npStart } from '../../../../legacy_singletons'; + +export const callValidateDatasetsAPI = async ( + indices: string[], + timestampField: string, + startTime: number, + endTime: number +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_VALIDATE_DATASETS_PATH, { + method: 'POST', + body: JSON.stringify( + validateLogEntryDatasetsRequestPayloadRT.encode({ + data: { + endTime, + indices, + startTime, + timestampField, + }, + }) + ), + }); + + return decodeOrThrow(validateLogEntryDatasetsResponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx index 99c5a3df7c9b1c2..cecfea28100adfe 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx @@ -5,7 +5,7 @@ */ import { useCallback, useMemo } from 'react'; - +import { DatasetFilter } from '../../../../common/log_analysis'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { useModuleStatus } from './log_analysis_module_status'; import { ModuleDescriptor, ModuleSourceConfiguration } from './log_analysis_module_types'; @@ -48,10 +48,11 @@ export const useLogAnalysisModule = ({ createPromise: async ( selectedIndices: string[], start: number | undefined, - end: number | undefined + end: number | undefined, + datasetFilter: DatasetFilter ) => { dispatchModuleStatus({ type: 'startedSetup' }); - const setupResult = await moduleDescriptor.setUpModule(start, end, { + const setupResult = await moduleDescriptor.setUpModule(start, end, datasetFilter, { indices: selectedIndices, sourceId, spaceId, @@ -92,11 +93,16 @@ export const useLogAnalysisModule = ({ ]); const cleanUpAndSetUpModule = useCallback( - (selectedIndices: string[], start: number | undefined, end: number | undefined) => { + ( + selectedIndices: string[], + start: number | undefined, + end: number | undefined, + datasetFilter: DatasetFilter + ) => { dispatchModuleStatus({ type: 'startedSetup' }); cleanUpModule() .then(() => { - setUpModule(selectedIndices, start, end); + setUpModule(selectedIndices, start, end, datasetFilter); }) .catch(() => { dispatchModuleStatus({ type: 'failedSetup' }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts index dc9f25b49263598..cc9ef7301984467 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts @@ -8,7 +8,11 @@ import { DeleteJobsResponsePayload } from './api/ml_cleanup'; import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; import { GetMlModuleResponsePayload } from './api/ml_get_module'; import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api'; -import { ValidationIndicesResponsePayload } from '../../../../common/http_api/log_analysis'; +import { + ValidationIndicesResponsePayload, + ValidateLogEntryDatasetsResponsePayload, +} from '../../../../common/http_api/log_analysis'; +import { DatasetFilter } from '../../../../common/log_analysis'; export interface ModuleDescriptor { moduleId: string; @@ -20,12 +24,20 @@ export interface ModuleDescriptor { setUpModule: ( start: number | undefined, end: number | undefined, + datasetFilter: DatasetFilter, sourceConfiguration: ModuleSourceConfiguration ) => Promise; cleanUpModule: (spaceId: string, sourceId: string) => Promise; validateSetupIndices: ( - sourceConfiguration: ModuleSourceConfiguration + indices: string[], + timestampField: string ) => Promise; + validateSetupDatasets: ( + indices: string[], + timestampField: string, + startTime: number, + endTime: number + ) => Promise; } export interface ModuleSourceConfiguration { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts new file mode 100644 index 000000000000000..d46e8bc2485f6b3 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.ts @@ -0,0 +1,264 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEqual } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { usePrevious } from 'react-use'; +import { + combineDatasetFilters, + DatasetFilter, + filterDatasetFilter, + isExampleDataIndex, +} from '../../../../common/log_analysis'; +import { + AvailableIndex, + ValidationIndicesError, + ValidationIndicesUIError, +} from '../../../components/logging/log_analysis_setup/initial_configuration_step'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { ModuleDescriptor, ModuleSourceConfiguration } from './log_analysis_module_types'; + +type SetupHandler = ( + indices: string[], + startTime: number | undefined, + endTime: number | undefined, + datasetFilter: DatasetFilter +) => void; + +interface AnalysisSetupStateArguments { + cleanUpAndSetUpModule: SetupHandler; + moduleDescriptor: ModuleDescriptor; + setUpModule: SetupHandler; + sourceConfiguration: ModuleSourceConfiguration; +} + +const fourWeeksInMs = 86400000 * 7 * 4; + +export const useAnalysisSetupState = ({ + cleanUpAndSetUpModule, + moduleDescriptor: { validateSetupDatasets, validateSetupIndices }, + setUpModule, + sourceConfiguration, +}: AnalysisSetupStateArguments) => { + const [startTime, setStartTime] = useState(Date.now() - fourWeeksInMs); + const [endTime, setEndTime] = useState(undefined); + + const [validatedIndices, setValidatedIndices] = useState( + sourceConfiguration.indices.map(indexName => ({ + name: indexName, + validity: 'unknown' as const, + })) + ); + + const updateIndicesWithValidationErrors = useCallback( + (validationErrors: ValidationIndicesError[]) => + setValidatedIndices(availableIndices => + availableIndices.map(previousAvailableIndex => { + const indexValiationErrors = validationErrors.filter( + ({ index }) => index === previousAvailableIndex.name + ); + + if (indexValiationErrors.length > 0) { + return { + validity: 'invalid', + name: previousAvailableIndex.name, + errors: indexValiationErrors, + }; + } else if (previousAvailableIndex.validity === 'valid') { + return { + ...previousAvailableIndex, + validity: 'valid', + errors: [], + }; + } else { + return { + validity: 'valid', + name: previousAvailableIndex.name, + isSelected: !isExampleDataIndex(previousAvailableIndex.name), + availableDatasets: [], + datasetFilter: { + type: 'includeAll' as const, + }, + }; + } + }) + ), + [] + ); + + const updateIndicesWithAvailableDatasets = useCallback( + (availableDatasets: Array<{ indexName: string; datasets: string[] }>) => + setValidatedIndices(availableIndices => + availableIndices.map(previousAvailableIndex => { + if (previousAvailableIndex.validity !== 'valid') { + return previousAvailableIndex; + } + + const availableDatasetsForIndex = availableDatasets.filter( + ({ indexName }) => indexName === previousAvailableIndex.name + ); + const newAvailableDatasets = availableDatasetsForIndex.flatMap( + ({ datasets }) => datasets + ); + + // filter out datasets that have disappeared if this index' datasets were updated + const newDatasetFilter: DatasetFilter = + availableDatasetsForIndex.length > 0 + ? filterDatasetFilter(previousAvailableIndex.datasetFilter, dataset => + newAvailableDatasets.includes(dataset) + ) + : previousAvailableIndex.datasetFilter; + + return { + ...previousAvailableIndex, + availableDatasets: newAvailableDatasets, + datasetFilter: newDatasetFilter, + }; + }) + ), + [] + ); + + const validIndexNames = useMemo( + () => validatedIndices.filter(index => index.validity === 'valid').map(index => index.name), + [validatedIndices] + ); + + const selectedIndexNames = useMemo( + () => + validatedIndices + .filter(index => index.validity === 'valid' && index.isSelected) + .map(i => i.name), + [validatedIndices] + ); + + const datasetFilter = useMemo( + () => + validatedIndices + .flatMap(validatedIndex => + validatedIndex.validity === 'valid' + ? validatedIndex.datasetFilter + : { type: 'includeAll' as const } + ) + .reduce(combineDatasetFilters, { type: 'includeAll' as const }), + [validatedIndices] + ); + + const [validateIndicesRequest, validateIndices] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async () => { + return await validateSetupIndices( + sourceConfiguration.indices, + sourceConfiguration.timestampField + ); + }, + onResolve: ({ data: { errors } }) => { + updateIndicesWithValidationErrors(errors); + }, + onReject: () => { + setValidatedIndices([]); + }, + }, + [sourceConfiguration.indices, sourceConfiguration.timestampField] + ); + + const [validateDatasetsRequest, validateDatasets] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: async () => { + if (validIndexNames.length === 0) { + return { data: { datasets: [] } }; + } + + return await validateSetupDatasets( + validIndexNames, + sourceConfiguration.timestampField, + startTime ?? 0, + endTime ?? Date.now() + ); + }, + onResolve: ({ data: { datasets } }) => { + updateIndicesWithAvailableDatasets(datasets); + }, + }, + [validIndexNames, sourceConfiguration.timestampField, startTime, endTime] + ); + + const setUp = useCallback(() => { + return setUpModule(selectedIndexNames, startTime, endTime, datasetFilter); + }, [setUpModule, selectedIndexNames, startTime, endTime, datasetFilter]); + + const cleanUpAndSetUp = useCallback(() => { + return cleanUpAndSetUpModule(selectedIndexNames, startTime, endTime, datasetFilter); + }, [cleanUpAndSetUpModule, selectedIndexNames, startTime, endTime, datasetFilter]); + + const isValidating = useMemo( + () => validateIndicesRequest.state === 'pending' || validateDatasetsRequest.state === 'pending', + [validateDatasetsRequest.state, validateIndicesRequest.state] + ); + + const validationErrors = useMemo(() => { + if (isValidating) { + return []; + } + + if (validateIndicesRequest.state === 'rejected') { + return [{ error: 'NETWORK_ERROR' }]; + } + + if (selectedIndexNames.length === 0) { + return [{ error: 'TOO_FEW_SELECTED_INDICES' }]; + } + + return validatedIndices.reduce((errors, index) => { + return index.validity === 'invalid' && selectedIndexNames.includes(index.name) + ? [...errors, ...index.errors] + : errors; + }, []); + }, [isValidating, validateIndicesRequest.state, selectedIndexNames, validatedIndices]); + + const prevStartTime = usePrevious(startTime); + const prevEndTime = usePrevious(endTime); + const prevValidIndexNames = usePrevious(validIndexNames); + + useEffect(() => { + validateIndices(); + }, [validateIndices]); + + useEffect(() => { + if ( + startTime !== prevStartTime || + endTime !== prevEndTime || + !isEqual(validIndexNames, prevValidIndexNames) + ) { + validateDatasets(); + } + }, [ + endTime, + prevEndTime, + prevStartTime, + prevValidIndexNames, + startTime, + validIndexNames, + validateDatasets, + ]); + + return { + cleanUpAndSetUp, + datasetFilter, + endTime, + isValidating, + selectedIndexNames, + setEndTime, + setStartTime, + setUp, + startTime, + validatedIndices, + setValidatedIndices, + validationErrors, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.tsx deleted file mode 100644 index 9f966ed3342e6f0..000000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_setup_state.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { isExampleDataIndex } from '../../../../common/log_analysis'; -import { - ValidatedIndex, - ValidationIndicesUIError, -} from '../../../components/logging/log_analysis_setup/initial_configuration_step'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { ModuleDescriptor, ModuleSourceConfiguration } from './log_analysis_module_types'; - -type SetupHandler = ( - indices: string[], - startTime: number | undefined, - endTime: number | undefined -) => void; - -interface AnalysisSetupStateArguments { - cleanUpAndSetUpModule: SetupHandler; - moduleDescriptor: ModuleDescriptor; - setUpModule: SetupHandler; - sourceConfiguration: ModuleSourceConfiguration; -} - -const fourWeeksInMs = 86400000 * 7 * 4; - -export const useAnalysisSetupState = ({ - cleanUpAndSetUpModule, - moduleDescriptor: { validateSetupIndices }, - setUpModule, - sourceConfiguration, -}: AnalysisSetupStateArguments) => { - const [startTime, setStartTime] = useState(Date.now() - fourWeeksInMs); - const [endTime, setEndTime] = useState(undefined); - - const [validatedIndices, setValidatedIndices] = useState([]); - - const [validateIndicesRequest, validateIndices] = useTrackedPromise( - { - cancelPreviousOn: 'resolution', - createPromise: async () => { - return await validateSetupIndices(sourceConfiguration); - }, - onResolve: ({ data: { errors } }) => { - setValidatedIndices(previousValidatedIndices => - sourceConfiguration.indices.map(indexName => { - const previousValidatedIndex = previousValidatedIndices.filter( - ({ name }) => name === indexName - )[0]; - const indexValiationErrors = errors.filter(({ index }) => index === indexName); - if (indexValiationErrors.length > 0) { - return { - validity: 'invalid', - name: indexName, - errors: indexValiationErrors, - }; - } else { - return { - validity: 'valid', - name: indexName, - isSelected: - previousValidatedIndex?.validity === 'valid' - ? previousValidatedIndex?.isSelected - : !isExampleDataIndex(indexName), - }; - } - }) - ); - }, - onReject: () => { - setValidatedIndices([]); - }, - }, - [sourceConfiguration.indices] - ); - - useEffect(() => { - validateIndices(); - }, [validateIndices]); - - const selectedIndexNames = useMemo( - () => - validatedIndices - .filter(index => index.validity === 'valid' && index.isSelected) - .map(i => i.name), - [validatedIndices] - ); - - const setUp = useCallback(() => { - return setUpModule(selectedIndexNames, startTime, endTime); - }, [setUpModule, selectedIndexNames, startTime, endTime]); - - const cleanUpAndSetUp = useCallback(() => { - return cleanUpAndSetUpModule(selectedIndexNames, startTime, endTime); - }, [cleanUpAndSetUpModule, selectedIndexNames, startTime, endTime]); - - const isValidating = useMemo( - () => - validateIndicesRequest.state === 'pending' || - validateIndicesRequest.state === 'uninitialized', - [validateIndicesRequest.state] - ); - - const validationErrors = useMemo(() => { - if (isValidating) { - return []; - } - - if (validateIndicesRequest.state === 'rejected') { - return [{ error: 'NETWORK_ERROR' }]; - } - - if (selectedIndexNames.length === 0) { - return [{ error: 'TOO_FEW_SELECTED_INDICES' }]; - } - - return validatedIndices.reduce((errors, index) => { - return index.validity === 'invalid' && selectedIndexNames.includes(index.name) - ? [...errors, ...index.errors] - : errors; - }, []); - }, [isValidating, validateIndicesRequest.state, selectedIndexNames, validatedIndices]); - - return { - cleanUpAndSetUp, - endTime, - isValidating, - selectedIndexNames, - setEndTime, - setStartTime, - setUp, - startTime, - validatedIndices, - setValidatedIndices, - validationErrors, - }; -}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts index 786cb485b38dd5d..e847302a6d367d4 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_configuration.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpSetup } from 'src/core/public'; import { getLogSourceConfigurationPath, getLogSourceConfigurationSuccessResponsePayloadRT, } from '../../../../../common/http_api/log_sources'; import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { npStart } from '../../../../legacy_singletons'; -export const callFetchLogSourceConfigurationAPI = async (sourceId: string) => { - const response = await npStart.http.fetch(getLogSourceConfigurationPath(sourceId), { +export const callFetchLogSourceConfigurationAPI = async ( + sourceId: string, + fetch: HttpSetup['fetch'] +) => { + const response = await fetch(getLogSourceConfigurationPath(sourceId), { method: 'GET', }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts index 2f1d15ffaf4d3dc..20e67a0a59c9fbf 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/fetch_log_source_status.ts @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpSetup } from 'src/core/public'; import { getLogSourceStatusPath, getLogSourceStatusSuccessResponsePayloadRT, } from '../../../../../common/http_api/log_sources'; import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { npStart } from '../../../../legacy_singletons'; -export const callFetchLogSourceStatusAPI = async (sourceId: string) => { - const response = await npStart.http.fetch(getLogSourceStatusPath(sourceId), { +export const callFetchLogSourceStatusAPI = async (sourceId: string, fetch: HttpSetup['fetch']) => { + const response = await fetch(getLogSourceStatusPath(sourceId), { method: 'GET', }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts b/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts index 848801ab3c7ce7d..4361e4bef827f76 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/api/patch_log_source_configuration.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpSetup } from 'src/core/public'; import { getLogSourceConfigurationPath, patchLogSourceConfigurationSuccessResponsePayloadRT, @@ -11,13 +12,13 @@ import { LogSourceConfigurationPropertiesPatch, } from '../../../../../common/http_api/log_sources'; import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { npStart } from '../../../../legacy_singletons'; export const callPatchLogSourceConfigurationAPI = async ( sourceId: string, - patchedProperties: LogSourceConfigurationPropertiesPatch + patchedProperties: LogSourceConfigurationPropertiesPatch, + fetch: HttpSetup['fetch'] ) => { - const response = await npStart.http.fetch(getLogSourceConfigurationPath(sourceId), { + const response = await fetch(getLogSourceConfigurationPath(sourceId), { method: 'PATCH', body: JSON.stringify( patchLogSourceConfigurationRequestBodyRT.encode({ diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts index 8332018fddf9017..670988d6801471f 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts @@ -6,6 +6,7 @@ import createContainer from 'constate'; import { useState, useMemo, useCallback } from 'react'; +import { HttpSetup } from 'src/core/public'; import { LogSourceConfiguration, LogSourceStatus, @@ -24,7 +25,13 @@ export { LogSourceStatus, }; -export const useLogSource = ({ sourceId }: { sourceId: string }) => { +export const useLogSource = ({ + sourceId, + fetch, +}: { + sourceId: string; + fetch: HttpSetup['fetch']; +}) => { const [sourceConfiguration, setSourceConfiguration] = useState< LogSourceConfiguration | undefined >(undefined); @@ -35,40 +42,40 @@ export const useLogSource = ({ sourceId }: { sourceId: string }) => { { cancelPreviousOn: 'resolution', createPromise: async () => { - return await callFetchLogSourceConfigurationAPI(sourceId); + return await callFetchLogSourceConfigurationAPI(sourceId, fetch); }, onResolve: ({ data }) => { setSourceConfiguration(data); }, }, - [sourceId] + [sourceId, fetch] ); const [updateSourceConfigurationRequest, updateSourceConfiguration] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async (patchedProperties: LogSourceConfigurationPropertiesPatch) => { - return await callPatchLogSourceConfigurationAPI(sourceId, patchedProperties); + return await callPatchLogSourceConfigurationAPI(sourceId, patchedProperties, fetch); }, onResolve: ({ data }) => { setSourceConfiguration(data); loadSourceStatus(); }, }, - [sourceId] + [sourceId, fetch] ); const [loadSourceStatusRequest, loadSourceStatus] = useTrackedPromise( { cancelPreviousOn: 'resolution', createPromise: async () => { - return await callFetchLogSourceStatusAPI(sourceId); + return await callFetchLogSourceStatusAPI(sourceId, fetch); }, onResolve: ({ data }) => { setSourceStatus(data); }, }, - [sourceId] + [sourceId, fetch] ); const logIndicesExist = useMemo(() => (sourceStatus?.logIndexNames?.length ?? 0) > 0, [ @@ -114,6 +121,10 @@ export const useLogSource = ({ sourceId }: { sourceId: string }) => { [loadSourceConfigurationRequest.state] ); + const hasFailedLoadingSourceStatus = useMemo(() => loadSourceStatusRequest.state === 'rejected', [ + loadSourceStatusRequest.state, + ]); + const loadSourceFailureMessage = useMemo( () => loadSourceConfigurationRequest.state === 'rejected' @@ -137,6 +148,7 @@ export const useLogSource = ({ sourceId }: { sourceId: string }) => { return { derivedIndexPattern, hasFailedLoadingSource, + hasFailedLoadingSourceStatus, initialize, isLoading, isLoadingSourceConfiguration, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts index be7547f2e74cb9b..45cdd28bd943bb5 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts @@ -7,20 +7,21 @@ import { bucketSpan, categoriesMessageField, + DatasetFilter, getJobId, LogEntryCategoriesJobType, logEntryCategoriesJobTypes, partitionField, } from '../../../../common/log_analysis'; - import { + cleanUpJobsAndDatafeeds, ModuleDescriptor, ModuleSourceConfiguration, - cleanUpJobsAndDatafeeds, } from '../../../containers/logs/log_analysis'; import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; const moduleId = 'logs_ui_categories'; @@ -48,6 +49,7 @@ const getModuleDefinition = async () => { const setUpModule = async ( start: number | undefined, end: number | undefined, + datasetFilter: DatasetFilter, { spaceId, sourceId, indices, timestampField }: ModuleSourceConfiguration ) => { const indexNamePattern = indices.join(','); @@ -65,10 +67,31 @@ const setUpModule = async ( indexPattern: indexNamePattern, timestampField, bucketSpan, + datasetFilter, }, }, }, ]; + const query = { + bool: { + filter: [ + ...(datasetFilter.type === 'includeSome' + ? [ + { + terms: { + 'event.dataset': datasetFilter.datasets, + }, + }, + ] + : []), + { + exists: { + field: 'message', + }, + }, + ], + }, + }; return callSetupMlModuleAPI( moduleId, @@ -77,7 +100,9 @@ const setUpModule = async ( spaceId, sourceId, indexNamePattern, - jobOverrides + jobOverrides, + [], + query ); }; @@ -85,7 +110,7 @@ const cleanUpModule = async (spaceId: string, sourceId: string) => { return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryCategoriesJobTypes); }; -const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { +const validateSetupIndices = async (indices: string[], timestampField: string) => { return await callValidateIndicesAPI(indices, [ { name: timestampField, @@ -102,6 +127,15 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; +const validateSetupDatasets = async ( + indices: string[], + timestampField: string, + startTime: number, + endTime: number +) => { + return await callValidateDatasetsAPI(indices, timestampField, startTime, endTime); +}; + export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, jobTypes: logEntryCategoriesJobTypes, @@ -111,5 +145,6 @@ export const logEntryCategoriesModule: ModuleDescriptor { createProcessStep({ cleanUpAndSetUp, errorMessages: lastSetupErrorMessages, - isConfigurationValid: validationErrors.length <= 0, + isConfigurationValid: validationErrors.length <= 0 && !isValidating, setUp, setupStatus, viewResults, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts index 52ba3101dbc38e7..dfd427138aaa678 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts @@ -6,20 +6,21 @@ import { bucketSpan, + DatasetFilter, getJobId, LogEntryRateJobType, logEntryRateJobTypes, partitionField, } from '../../../../common/log_analysis'; - import { + cleanUpJobsAndDatafeeds, ModuleDescriptor, ModuleSourceConfiguration, - cleanUpJobsAndDatafeeds, } from '../../../containers/logs/log_analysis'; import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; const moduleId = 'logs_ui_analysis'; @@ -47,6 +48,7 @@ const getModuleDefinition = async () => { const setUpModule = async ( start: number | undefined, end: number | undefined, + datasetFilter: DatasetFilter, { spaceId, sourceId, indices, timestampField }: ModuleSourceConfiguration ) => { const indexNamePattern = indices.join(','); @@ -68,6 +70,20 @@ const setUpModule = async ( }, }, ]; + const query = + datasetFilter.type === 'includeSome' + ? { + bool: { + filter: [ + { + terms: { + 'event.dataset': datasetFilter.datasets, + }, + }, + ], + }, + } + : undefined; return callSetupMlModuleAPI( moduleId, @@ -76,7 +92,9 @@ const setUpModule = async ( spaceId, sourceId, indexNamePattern, - jobOverrides + jobOverrides, + [], + query ); }; @@ -84,7 +102,7 @@ const cleanUpModule = async (spaceId: string, sourceId: string) => { return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryRateJobTypes); }; -const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { +const validateSetupIndices = async (indices: string[], timestampField: string) => { return await callValidateIndicesAPI(indices, [ { name: timestampField, @@ -97,6 +115,15 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; +const validateSetupDatasets = async ( + indices: string[], + timestampField: string, + startTime: number, + endTime: number +) => { + return await callValidateDatasetsAPI(indices, timestampField, startTime, endTime); +}; + export const logEntryRateModule: ModuleDescriptor = { moduleId, jobTypes: logEntryRateJobTypes, @@ -106,5 +133,6 @@ export const logEntryRateModule: ModuleDescriptor = { getModuleDefinition, setUpModule, cleanUpModule, + validateSetupDatasets, validateSetupIndices, }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx index a02dbfa941588d0..e5c439808115d3c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_setup_content.tsx @@ -55,7 +55,7 @@ export const LogEntryRateSetupContent: React.FunctionComponent = () => { createProcessStep({ cleanUpAndSetUp, errorMessages: lastSetupErrorMessages, - isConfigurationValid: validationErrors.length <= 0, + isConfigurationValid: validationErrors.length <= 0 && !isValidating, setUp, setupStatus, viewResults, diff --git a/x-pack/plugins/infra/public/pages/logs/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/page_providers.tsx index d2db5002f4aa220..1e053d8d4abc326 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_providers.tsx @@ -5,16 +5,16 @@ */ import React from 'react'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { LogAnalysisCapabilitiesProvider } from '../../containers/logs/log_analysis'; import { LogSourceProvider } from '../../containers/logs/log_source'; -// import { SourceProvider } from '../../containers/source'; import { useSourceId } from '../../containers/source_id'; export const LogsPageProviders: React.FunctionComponent = ({ children }) => { const [sourceId] = useSourceId(); - + const { services } = useKibana(); return ( - + {children} ); diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 4ed30380dc164d7..06135c6532d7789 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -15,6 +15,7 @@ import { initGetLogEntryCategoryDatasetsRoute, initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, + initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; @@ -51,6 +52,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initSnapshotRoute(libs); initNodeDetailsRoute(libs); initSourceRoute(libs); + initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initLogEntriesRoute(libs); initLogEntriesHighlightsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/compose/kibana.ts b/x-pack/plugins/infra/server/lib/compose/kibana.ts index d22ca2961cfa538..626b9d46bbde30e 100644 --- a/x-pack/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/plugins/infra/server/lib/compose/kibana.ts @@ -38,6 +38,7 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ sources, }), logEntries: new InfraLogEntriesDomain(new InfraKibanaLogEntriesAdapter(framework), { + framework, sources, }), metrics: new InfraMetricsDomain(new KibanaMetricsAdapter(framework)), diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 07bc965dda77a6a..15bfbce6d512eca 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -29,6 +29,14 @@ import { Highlights, compileFormattingRules, } from './message'; +import { KibanaFramework } from '../../adapters/framework/kibana_framework_adapter'; +import { decodeOrThrow } from '../../../../common/runtime_types'; +import { + logEntryDatasetsResponseRT, + LogEntryDatasetBucket, + CompositeDatasetKey, + createLogEntryDatasetsQuery, +} from './queries/log_entry_datasets'; export interface LogEntriesParams { startTimestamp: number; @@ -51,10 +59,15 @@ export const LOG_ENTRIES_PAGE_SIZE = 200; const FIELDS_FROM_CONTEXT = ['log.file.path', 'host.name', 'container.id'] as const; +const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; + export class InfraLogEntriesDomain { constructor( private readonly adapter: LogEntriesAdapter, - private readonly libs: { sources: InfraSources } + private readonly libs: { + framework: KibanaFramework; + sources: InfraSources; + } ) {} public async getLogEntriesAround( @@ -256,6 +269,45 @@ export class InfraLogEntriesDomain { ), }; } + + public async getLogEntryDatasets( + requestContext: RequestHandlerContext, + timestampField: string, + indexName: string, + startTime: number, + endTime: number + ) { + let datasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + + while (true) { + const datasetsReponse = await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryDatasetsQuery( + indexName, + timestampField, + startTime, + endTime, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ); + + const { after_key: afterKey, buckets: latestBatchBuckets } = decodeOrThrow( + logEntryDatasetsResponseRT + )(datasetsReponse).aggregations.dataset_buckets; + + datasetBuckets = [...datasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + return datasetBuckets.map(({ key: { dataset } }) => dataset); + } } interface LogItemHit { diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts new file mode 100644 index 000000000000000..1df7072904f68e9 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/queries/log_entry_datasets.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../../utils/elasticsearch_runtime_types'; + +export const createLogEntryDatasetsQuery = ( + indexName: string, + timestampField: string, + startTime: number, + endTime: number, + size: number, + afterKey?: CompositeDatasetKey +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: startTime, + lte: endTime, + }, + }, + }, + { + exists: { + field: 'event.dataset', + }, + }, + ], + }, + }, + aggs: { + dataset_buckets: { + composite: { + after: afterKey, + size, + sources: [ + { + dataset: { + terms: { + field: 'event.dataset', + order: 'asc', + }, + }, + }, + ], + }, + }, + }, + }, + index: indexName, + size: 0, +}); + +const defaultRequestParameters = { + allowNoIndices: true, + ignoreUnavailable: true, + trackScores: false, + trackTotalHits: false, +}; + +const compositeDatasetKeyRT = rt.type({ + dataset: rt.string, +}); + +export type CompositeDatasetKey = rt.TypeOf; + +const logEntryDatasetBucketRT = rt.type({ + key: compositeDatasetKeyRT, +}); + +export type LogEntryDatasetBucket = rt.TypeOf; + +export const logEntryDatasetsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + dataset_buckets: rt.intersection([ + rt.type({ + buckets: rt.array(logEntryDatasetBucketRT), + }), + rt.partial({ + after_key: compositeDatasetKeyRT, + }), + ]), + }), + }), +]); + +export type LogEntryDatasetsResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index db34033c1d4f8de..13446594ab114e9 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -119,6 +119,7 @@ export class InfraServerPlugin { sources, }), logEntries: new InfraLogEntriesDomain(new InfraKibanaLogEntriesAdapter(framework), { + framework, sources, }), metrics: new InfraMetricsDomain(new KibanaMetricsAdapter(framework)), diff --git a/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts new file mode 100644 index 000000000000000..d772c000986fc5a --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/validation/datasets.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Boom from 'boom'; + +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_VALIDATE_DATASETS_PATH, + validateLogEntryDatasetsRequestPayloadRT, + validateLogEntryDatasetsResponsePayloadRT, +} from '../../../../common/http_api'; + +import { createValidationFunction } from '../../../../common/runtime_types'; + +export const initValidateLogAnalysisDatasetsRoute = ({ + framework, + logEntries, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_VALIDATE_DATASETS_PATH, + validate: { + body: createValidationFunction(validateLogEntryDatasetsRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + try { + const { + data: { indices, timestampField, startTime, endTime }, + } = request.body; + + const datasets = await Promise.all( + indices.map(async indexName => { + const indexDatasets = await logEntries.getLogEntryDatasets( + requestContext, + timestampField, + indexName, + startTime, + endTime + ); + + return { + indexName, + datasets: indexDatasets, + }; + }) + ); + + return response.ok({ + body: validateLogEntryDatasetsResponsePayloadRT.encode({ data: { datasets } }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/validation/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/validation/index.ts index 727faca69298eed..10c39f9552a3a2d 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/validation/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/validation/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './datasets'; export * from './indices'; diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 748bb14d2d35de4..b357d0c2d75f483 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -14,6 +14,7 @@ export interface IngestManagerConfigType { }; fleet: { enabled: boolean; + tlsCheckDisabled: boolean; defaultOutputHost: string; kibana: { host?: string; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts index 64ed95db74f4c18..7214611ca9122d8 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -152,7 +152,7 @@ export interface UpdateAgentRequest { export interface GetAgentStatusRequest { query: { - configId: string; + configId?: string; }; } diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts index c4ba8ee595acfc1..ae4cb4e3fce4981 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts @@ -7,3 +7,8 @@ export interface CreateFleetSetupResponse { isInitialized: boolean; } + +export interface GetFleetStatusResponse { + isReady: boolean; + missing_requirements: Array<'tls_required' | 'api_keys' | 'fleet_admin_user'>; +} diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index cef1a293c104bc8..382ea0444093d43 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,5 +5,5 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features"] + "optionalPlugins": ["security", "features", "cloud"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx new file mode 100644 index 000000000000000..ef40c171b9ca355 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useContext, useEffect } from 'react'; +import { useConfig } from './use_config'; +import { sendGetFleetStatus } from './use_request'; +import { GetFleetStatusResponse } from '../types'; + +interface FleetStatusState { + enabled: boolean; + isLoading: boolean; + isReady: boolean; + missingRequirements?: GetFleetStatusResponse['missing_requirements']; +} + +interface FleetStatus extends FleetStatusState { + refresh: () => Promise; +} + +const FleetStatusContext = React.createContext(undefined); + +export const FleetStatusProvider: React.FC = ({ children }) => { + const config = useConfig(); + const [state, setState] = useState({ + enabled: config.fleet.enabled, + isLoading: false, + isReady: false, + }); + async function sendGetStatus() { + try { + setState(s => ({ ...s, isLoading: true })); + const res = await sendGetFleetStatus(); + if (res.error) { + throw res.error; + } + + setState(s => ({ + ...s, + isLoading: false, + isReady: res.data?.isReady ?? false, + missingRequirements: res.data?.missing_requirements, + })); + } catch (error) { + setState(s => ({ ...s, isLoading: true })); + } + } + useEffect(() => { + sendGetStatus(); + }, []); + + return ( + sendGetStatus() }}> + {children} + + ); +}; + +export function useFleetStatus(): FleetStatus { + const context = useContext(FleetStatusContext); + + if (!context) { + throw new Error('FleetStatusContext not set'); + } + + return context; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts index 453bcf2bd81e7e5..cad1791af41bea6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -62,6 +62,15 @@ export function sendGetAgentStatus( }); } +export function useGetAgentStatus(query: GetAgentStatusRequest['query'], options?: RequestOptions) { + return useRequest({ + method: 'get', + path: agentRouteService.getStatusPath(), + query, + ...options, + }); +} + export function sendPutAgentReassign( agentId: string, body: PutAgentReassignRequest['body'], diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts index c39d2a5860bf000..25cdffc5c6651b9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts @@ -12,3 +12,4 @@ export * from './enrollment_api_keys'; export * from './epm'; export * from './outputs'; export * from './settings'; +export * from './setup'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts index 04fdf9f66948f06..e4e84e4701f139f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts @@ -5,7 +5,8 @@ */ import { sendRequest } from './use_request'; -import { setupRouteService } from '../../services'; +import { setupRouteService, fleetSetupRouteService } from '../../services'; +import { GetFleetStatusResponse } from '../../types'; export const sendSetup = () => { return sendRequest({ @@ -13,3 +14,10 @@ export const sendSetup = () => { method: 'post', }); }; + +export const sendGetFleetStatus = () => { + return sendRequest({ + path: fleetSetupRouteService.getFleetSetupPath(), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 295a35693726f39..f0a0c90a18c24e7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -23,6 +23,7 @@ import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { sendSetup } from './hooks/use_request/setup'; +import { FleetStatusProvider } from './hooks/use_fleet_status'; import './index.scss'; export interface ProtectedRouteProps extends RouteProps { @@ -142,7 +143,9 @@ const IngestManagerApp = ({ - + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx index dd34e7260b27b49..e9347ccd2d6c923 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx @@ -15,11 +15,15 @@ import { EuiButtonEmpty, EuiButton, EuiFlyoutFooter, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentConfig } from '../../../../../types'; import { APIKeySelection } from './key_selection'; import { EnrollmentInstructions } from './instructions'; +import { useFleetStatus } from '../../../../../hooks/use_fleet_status'; +import { useLink } from '../../../../../hooks'; +import { FLEET_PATH } from '../../../../../constants'; interface Props { onClose: () => void; @@ -30,8 +34,11 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentConfigs = [], }) => { + const fleetStatus = useFleetStatus(); const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const fleetLink = useLink(FLEET_PATH); + return ( @@ -45,12 +52,33 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ - setSelectedAPIKeyId(keyId)} - /> - - + {fleetStatus.isReady ? ( + <> + setSelectedAPIKeyId(keyId)} + /> + + + + ) : ( + <> + + + + ), + }} + /> + + )} @@ -62,14 +90,16 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ />
- - - - - + {fleetStatus.isReady && ( + + + + + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx index fac81ecc19cd185..b9c5418dbf6f32f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx @@ -6,35 +6,31 @@ import React from 'react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { Loading } from '../../components'; -import { useConfig, useCore, useRequest } from '../../hooks'; +import { useConfig, useCore } from '../../hooks'; import { AgentListPage } from './agent_list_page'; import { SetupPage } from './setup_page'; import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; -import { fleetSetupRouteService } from '../../services'; import { EnrollmentTokenListPage } from './enrollment_token_list_page'; import { ListLayout } from './components/list_layout'; +import { useFleetStatus } from '../../hooks/use_fleet_status'; export const FleetApp: React.FunctionComponent = () => { const core = useCore(); const { fleet } = useConfig(); - const setupRequest = useRequest({ - method: 'get', - path: fleetSetupRouteService.getFleetSetupPath(), - }); + const fleetStatus = useFleetStatus(); if (!fleet.enabled) return null; - if (setupRequest.isLoading) { + if (fleetStatus.isLoading) { return ; } - if (setupRequest.data.isInitialized === false) { + if (fleetStatus.isReady === false) { return ( { - await setupRequest.sendRequest(); - }} + missingRequirements={fleetStatus.missingRequirements || []} + refresh={fleetStatus.refresh} /> ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx index 96d4d01d67a4987..4d89268c14b2813 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -18,10 +18,12 @@ import { import { sendRequest, useCore } from '../../../hooks'; import { fleetSetupRouteService } from '../../../services'; import { WithoutHeaderLayout } from '../../../layouts'; +import { GetFleetStatusResponse } from '../../../types'; export const SetupPage: React.FunctionComponent<{ refresh: () => Promise; -}> = ({ refresh }) => { + missingRequirements: GetFleetStatusResponse['missing_requirements']; +}> = ({ refresh, missingRequirements }) => { const [isFormLoading, setIsFormLoading] = useState(false); const core = useCore(); @@ -40,46 +42,81 @@ export const SetupPage: React.FunctionComponent<{ } }; + const content = + missingRequirements.includes('tls_required') || missingRequirements.includes('api_keys') ? ( + <> + + + + +

+ +

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

+ +

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

- -

-
- - - - - - -
- - - -
-
- + {content}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx new file mode 100644 index 000000000000000..0f6d3c5b55ce69b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetAgentStatus } from '../../../hooks'; +import { FLEET_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; + +export const OverviewAgentSection = () => { + const agentStatusRequest = useGetAgentStatus({}); + + return ( + + +
+ +

+ +

+
+ + + +
+ + {agentStatusRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx new file mode 100644 index 000000000000000..b74cac9a621769a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetDatasources } from '../../../hooks'; +import { AgentConfig } from '../../../types'; +import { AGENT_CONFIG_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; + +export const OverviewConfigurationSection: React.FC<{ agentConfigs: AgentConfig[] }> = ({ + agentConfigs, +}) => { + const datasourcesRequest = useGetDatasources({ + page: 1, + perPage: 10000, + }); + + return ( + + +
+ +

+ +

+
+ + + +
+ + {datasourcesRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx new file mode 100644 index 000000000000000..7d1f0598a276735 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetDataStreams, useStartDeps } from '../../../hooks'; +import { Loading } from '../../fleet/components'; +import { DATA_STREAM_PATH } from '../../../constants'; + +export const OverviewDatastreamSection: React.FC = () => { + const datastreamRequest = useGetDataStreams(); + const { + data: { fieldFormats }, + } = useStartDeps(); + + const total = datastreamRequest.data?.data_streams?.length ?? 0; + let sizeBytes = 0; + const namespaces = new Set(); + if (datastreamRequest.data) { + datastreamRequest.data.data_streams.forEach(val => { + namespaces.add(val.namespace); + sizeBytes += val.size_in_bytes; + }); + } + + let size: string; + try { + const formatter = fieldFormats.getInstance('bytes'); + size = formatter.convert(sizeBytes); + } catch (e) { + size = `${sizeBytes}b`; + } + + return ( + + +
+ +

+ +

+
+ + + +
+ + {datastreamRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + + + {size} + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx new file mode 100644 index 000000000000000..f4c122af88371ce --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiButtonEmpty, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { OverviewPanel } from './overview_panel'; +import { OverviewStats } from './overview_stats'; +import { useLink, useGetPackages } from '../../../hooks'; +import { EPM_PATH } from '../../../constants'; +import { Loading } from '../../fleet/components'; +import { InstallationStatus } from '../../../types'; + +export const OverviewIntegrationSection: React.FC = () => { + const packagesRequest = useGetPackages(); + + const total = packagesRequest.data?.response?.length ?? 0; + const installed = + packagesRequest.data?.response?.filter(p => p.status === InstallationStatus.installed) + ?.length ?? 0; + return ( + + +
+ +

+ +

+
+ + + +
+ + {packagesRequest.isLoading ? ( + + ) : ( + <> + + + + + + + + + + + + + + )} + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx new file mode 100644 index 000000000000000..41d7a7a5f0bc3c0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { EuiPanel } from '@elastic/eui'; + +export const OverviewPanel = styled(EuiPanel).attrs(props => ({ + paddingSize: 'm', +}))` + header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid ${props => props.theme.eui.euiColorLightShade}; + margin: -${props => props.theme.eui.paddingSizes.m} -${props => props.theme.eui.paddingSizes.m} + ${props => props.theme.eui.paddingSizes.m}; + padding: ${props => props.theme.eui.paddingSizes.s} ${props => props.theme.eui.paddingSizes.m}; + } + + h2 { + padding: ${props => props.theme.eui.paddingSizes.xs} 0; + } +`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx new file mode 100644 index 000000000000000..04de22c34fe6f49 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_stats.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import styled from 'styled-components'; +import { EuiDescriptionList } from '@elastic/eui'; + +export const OverviewStats = styled(EuiDescriptionList).attrs(props => ({ + compressed: true, + textStyle: 'reverse', + type: 'column', +}))` + & > * { + margin-top: ${props => props.theme.eui.paddingSizes.s} !important; + + &:first-child, + &:nth-child(2) { + margin-top: 0 !important; + } + } +`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx index 70d8e7d6882f885..3cd778fb4f0168c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx @@ -7,14 +7,8 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { EuiButton, - EuiButtonEmpty, EuiBetaBadge, - EuiPanel, EuiText, - EuiTitle, - EuiDescriptionList, - EuiDescriptionListDescription, - EuiDescriptionListTitle, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, @@ -22,42 +16,12 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { WithHeaderLayout } from '../../layouts'; -import { useLink, useGetAgentConfigs } from '../../hooks'; +import { useGetAgentConfigs } from '../../hooks'; import { AgentEnrollmentFlyout } from '../fleet/agent_list_page/components'; -import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH, DATA_STREAM_PATH } from '../../constants'; - -const OverviewPanel = styled(EuiPanel).attrs(props => ({ - paddingSize: 'm', -}))` - header { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid ${props => props.theme.eui.euiColorLightShade}; - margin: -${props => props.theme.eui.paddingSizes.m} -${props => props.theme.eui.paddingSizes.m} - ${props => props.theme.eui.paddingSizes.m}; - padding: ${props => props.theme.eui.paddingSizes.s} ${props => props.theme.eui.paddingSizes.m}; - } - - h2 { - padding: ${props => props.theme.eui.paddingSizes.xs} 0; - } -`; - -const OverviewStats = styled(EuiDescriptionList).attrs(props => ({ - compressed: true, - textStyle: 'reverse', - type: 'column', -}))` - & > * { - margin-top: ${props => props.theme.eui.paddingSizes.s} !important; - - &:first-child, - &:nth-child(2) { - margin-top: 0 !important; - } - } -`; +import { OverviewAgentSection } from './components/agent_section'; +import { OverviewConfigurationSection } from './components/configuration_section'; +import { OverviewIntegrationSection } from './components/integration_section'; +import { OverviewDatastreamSection } from './components/datastream_section'; const AlphaBadge = styled(EuiBetaBadge)` vertical-align: top; @@ -135,121 +99,12 @@ export const IngestManagerOverview: React.FunctionComponent = () => { )} - - -
- -

- -

-
- - - -
- - Total available - 999 - Installed - 1 - Updated available - 0 - -
-
+ + - - -
- -

- -

-
- - - -
- - Total configs - 1 - Data sources - 1 - -
-
+ - - -
- -

- -

-
- - - -
- - Total agents - 0 - Active - 0 - Offline - 0 - Error - 0 - -
-
- - - -
- -

- -

-
- - - -
- - Data streams - 0 - Name spaces - 0 - Total size - 0 MB - -
-
+
); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 602015d23cefb99..ca5bf999aa81a35 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -20,6 +20,8 @@ export { DatasourceConfigRecordEntry, Output, DataStream, + // API schema - misc setup, status + GetFleetStatusResponse, // API schemas - Agent Config GetAgentConfigsResponse, GetAgentConfigsResponseItem, diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 951ff2337d8c724..6096af8d8080109 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -26,6 +26,7 @@ export const config = { }), fleet: schema.object({ enabled: schema.boolean({ defaultValue: true }), + tlsCheckDisabled: schema.boolean({ defaultValue: false }), kibana: schema.object({ host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 3448685d1f279f0..3b0837565c36cda 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, SavedObjectsServiceStart, + HttpServerInfo, } from 'kibana/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { @@ -42,7 +43,6 @@ import { registerOutputRoutes, registerSettingsRoutes, } from './routes'; - import { IngestManagerConfigType } from '../common'; import { appContextService, @@ -52,12 +52,14 @@ import { AgentService, } from './services'; import { getAgentStatusById } from './services/agents'; +import { CloudSetup } from '../../cloud/server'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; features?: FeaturesPluginSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; + cloud?: CloudSetup; } export type IngestManagerStartDeps = object; @@ -67,6 +69,9 @@ export interface IngestManagerAppContext { security?: SecurityPluginSetup; config$?: Observable; savedObjects: SavedObjectsServiceStart; + isProductionMode: boolean; + serverInfo?: HttpServerInfo; + cloud?: CloudSetup; } export type IngestManagerSetupContract = void; @@ -100,16 +105,23 @@ export class IngestManagerPlugin private licensing$!: Observable; private config$: Observable; private security: SecurityPluginSetup | undefined; + private cloud: CloudSetup | undefined; + + private isProductionMode: boolean; + private serverInfo: HttpServerInfo | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); + this.isProductionMode = this.initializerContext.env.mode.prod; } public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { + this.serverInfo = core.http.getServerInfo(); this.licensing$ = deps.licensing.license$; if (deps.security) { this.security = deps.security; } + this.cloud = deps.cloud; registerSavedObjects(core.savedObjects); registerEncryptedSavedObjects(deps.encryptedSavedObjects); @@ -184,6 +196,9 @@ export class IngestManagerPlugin security: this.security, config$: this.config$, savedObjects: core.savedObjects, + isProductionMode: this.isProductionMode, + serverInfo: this.serverInfo, + cloud: this.cloud, }); licenseService.start(this.licensing$); return { diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 837e73b966feba7..542dfa9cefe8f98 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -4,28 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ import { RequestHandler } from 'src/core/server'; -import { outputService } from '../../services'; -import { CreateFleetSetupResponse } from '../../../common'; +import { outputService, appContextService } from '../../services'; +import { GetFleetStatusResponse } from '../../../common'; import { setupIngestManager, setupFleet } from '../../services/setup'; -export const getFleetSetupHandler: RequestHandler = async (context, request, response) => { +export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; - const successBody: CreateFleetSetupResponse = { isInitialized: true }; - const failureBody: CreateFleetSetupResponse = { isInitialized: false }; try { - const adminUser = await outputService.getAdminUser(soClient); - if (adminUser) { - return response.ok({ - body: successBody, - }); - } else { - return response.ok({ - body: failureBody, - }); + const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null; + const isApiKeysEnabled = await appContextService.getSecurity().authc.areAPIKeysEnabled(); + const isTLSEnabled = appContextService.getServerInfo().protocol === 'https'; + const isProductionMode = appContextService.getIsProductionMode(); + const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; + const isTLSCheckDisabled = appContextService.getConfig()?.fleet?.tlsCheckDisabled ?? false; + + const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; + if (!isAdminUserSetup) { + missingRequirements.push('fleet_admin_user'); } - } catch (e) { + if (!isApiKeysEnabled) { + missingRequirements.push('api_keys'); + } + if (!isTLSCheckDisabled && !isCloud && isProductionMode && !isTLSEnabled) { + missingRequirements.push('tls_required'); + } + + const body: GetFleetStatusResponse = { + isReady: missingRequirements.length === 0, + missing_requirements: missingRequirements, + }; + return response.ok({ - body: failureBody, + body, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts index 5ee7ee7733220d7..43dcf47d26c182f 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { IRouter } from 'src/core/server'; + import { PLUGIN_ID, FLEET_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import { IngestManagerConfigType } from '../../../common'; import { - getFleetSetupHandler, + getFleetStatusHandler, createFleetSetupHandler, ingestManagerSetupHandler, } from './handlers'; @@ -36,7 +37,7 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) validate: false, options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - getFleetSetupHandler + getFleetStatusHandler ); // Create Fleet setup diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5ecbaff8ad71e10..84bcd7db3f7b1ec 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -314,10 +314,12 @@ class AgentConfigService { if (!config) { return null; } - const defaultOutput = await outputService.get( - soClient, - await outputService.getDefaultOutputId(soClient) - ); + + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output is not setup'); + } + const defaultOutput = await outputService.get(soClient, defaultOutputId); const agentConfig: FullAgentConfig = { id: config.id, diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index e917d2edd13090c..5e538ad84b4c210 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -5,11 +5,12 @@ */ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { SavedObjectsServiceStart } from 'src/core/server'; +import { SavedObjectsServiceStart, HttpServerInfo } from 'src/core/server'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; import { IngestManagerAppContext } from '../plugin'; +import { CloudSetup } from '../../../cloud/server'; class AppContextService { private encryptedSavedObjects: EncryptedSavedObjectsPluginStart | undefined; @@ -17,11 +18,17 @@ class AppContextService { private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; + private serverInfo: HttpServerInfo | undefined; + private isProductionMode: boolean = false; + private cloud?: CloudSetup; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; this.savedObjects = appContext.savedObjects; + this.serverInfo = appContext.serverInfo; + this.isProductionMode = appContext.isProductionMode; + this.cloud = appContext.cloud; if (appContext.config$) { this.config$ = appContext.config$; @@ -41,9 +48,16 @@ class AppContextService { } public getSecurity() { + if (!this.security) { + throw new Error('Secury service not set.'); + } return this.security; } + public getCloud() { + return this.cloud; + } + public getConfig() { return this.configSubject$?.value; } @@ -58,6 +72,17 @@ class AppContextService { } return this.savedObjects; } + + public getIsProductionMode() { + return this.isProductionMode; + } + + public getServerInfo() { + if (!this.serverInfo) { + throw new Error('Server info not set.'); + } + return this.serverInfo; + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index affd9b2755881a8..0497bc5a2b541b5 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -196,6 +196,9 @@ class DatasourceService { outputService.getDefaultOutputId(soClient), ]); if (pkgInfo) { + if (!defaultOutputId) { + throw new Error('Default output is not set'); + } return packageToConfigDatasource(pkgInfo, '', defaultOutputId); } } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts index 0d9db1697d8863a..23e63f0a89a5ea7 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -6,11 +6,12 @@ import { SavedObjectsClientContract } from 'src/core/server'; import Boom from 'boom'; -import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import { PACKAGES_SAVED_OBJECT_TYPE, DATASOURCE_SAVED_OBJECT_TYPE } from '../../../constants'; import { AssetReference, AssetType, ElasticsearchAssetType } from '../../../types'; import { CallESAsCurrentUser } from '../../../types'; import { getInstallation, savedObjectTypes } from './index'; import { installIndexPatterns } from '../kibana/index_pattern/install'; +import { datasourceService } from '../..'; export async function removeInstallation(options: { savedObjectsClient: SavedObjectsClientContract; @@ -26,6 +27,17 @@ export async function removeInstallation(options: { throw Boom.badRequest(`${pkgName} is installed by default and cannot be removed`); const installedObjects = installation.installed || []; + const { total } = await datasourceService.list(savedObjectsClient, { + kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, + page: 0, + perPage: 0, + }); + + if (total > 0) + throw Boom.badRequest( + `unable to remove package with existing datasource(s) in use by agent(s)` + ); + // Delete the manager saved object with references to the asset objects // could also update with [] or some other state await savedObjectsClient.delete(PACKAGES_SAVED_OBJECT_TYPE, pkgName); diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index 395c9af4a4ca217..3628c5bd9e18307 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -48,7 +48,7 @@ class OutputService { }); if (!outputs.saved_objects.length) { - throw new Error('No default output'); + return null; } return outputs.saved_objects[0].id; @@ -56,6 +56,9 @@ class OutputService { public async getAdminUser(soClient: SavedObjectsClientContract) { const defaultOutputId = await this.getDefaultOutputId(soClient); + if (!defaultOutputId) { + return null; + } const so = await appContextService .getEncryptedSavedObjects() ?.getDecryptedAsInternalUser(OUTPUT_SAVED_OBJECT_TYPE, defaultOutputId); diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 390e240841611e7..3619628bd4f8b97 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -109,7 +109,12 @@ export async function setupFleet( }); // save fleet admin user - await outputService.updateOutput(soClient, await outputService.getDefaultOutputId(soClient), { + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output does not exist'); + } + + await outputService.updateOutput(soClient, defaultOutputId, { fleet_enroll_username: FLEET_ENROLL_USERNAME, fleet_enroll_password: password, }); diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ems/boundaries_screenshot.png b/x-pack/plugins/maps/public/assets/boundaries_screenshot.png similarity index 100% rename from src/legacy/core_plugins/kibana/public/home/tutorial_resources/ems/boundaries_screenshot.png rename to x-pack/plugins/maps/public/assets/boundaries_screenshot.png diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index d628cca61de1136..e0fdff62829fb99 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -75,7 +75,7 @@ export class TOCEntryActionsPopover extends Component { } _removeLayer() { - this.props.fitToBounds(this.props.layer.getId()); + this.props.removeLayer(this.props.layer.getId()); } _toggleVisible() { diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 038f61b3a33b7b1..e9d4aff3484b1c8 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -13,7 +13,9 @@ "home", "licensing", "usageCollection", - "share" + "share", + "embeddable", + "uiActions" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 9cc42a4df2f66cf..decd1275fe88443 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -4,56 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; -import React, { useRef, FC } from 'react'; +import TooltipTrigger from 'react-popper-tooltip'; import { TooltipValueFormatter } from '@elastic/charts'; -import useObservable from 'react-use/lib/useObservable'; -import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service'; +import './_index.scss'; -type RefValue = HTMLElement | null; - -function useRefWithCallback(chartTooltipState?: ChartTooltipState) { - const ref = useRef(null); - - return (node: RefValue) => { - ref.current = node; - - if ( - node !== null && - node.parentElement !== null && - chartTooltipState !== undefined && - chartTooltipState.isTooltipVisible - ) { - const parentBounding = node.parentElement.getBoundingClientRect(); - - const { targetPosition, offset } = chartTooltipState; - - const contentWidth = document.body.clientWidth - parentBounding.left; - const tooltipWidth = node.clientWidth; - - let left = targetPosition.left + offset.x - parentBounding.left; - if (left + tooltipWidth > contentWidth) { - // the tooltip is hanging off the side of the page, - // so move it to the other side of the target - left = left - (tooltipWidth + offset.x); - } - - const top = targetPosition.top + offset.y - parentBounding.top; - - if ( - chartTooltipState.tooltipPosition.left !== left || - chartTooltipState.tooltipPosition.top !== top - ) { - // render the tooltip with adjusted position. - chartTooltip$.next({ - ...chartTooltipState, - tooltipPosition: { left, top }, - }); - } - } - }; -} +import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types'; +import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service'; const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -63,48 +22,101 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; -export const ChartTooltip: FC = () => { - const chartTooltipState = useObservable(chartTooltip$); - const chartTooltipElement = useRefWithCallback(chartTooltipState); +const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { + const [tooltipData, setData] = useState([]); + const refCallback = useRef(); - if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { - return
; - } + useEffect(() => { + const subscription = service.tooltipState$.subscribe(tooltipState => { + if (refCallback.current) { + // update trigger + refCallback.current(tooltipState.target); + } + setData(tooltipState.tooltipData); + }); + return () => { + subscription.unsubscribe(); + }; + }, []); + + const triggerCallback = useCallback( + (({ triggerRef }) => { + // obtain the reference to the trigger setter callback + // to update the target based on changes from the service. + refCallback.current = triggerRef; + // actual trigger is resolved by the service, hence don't render + return null; + }) as TooltipTriggerProps['children'], + [] + ); + + const tooltipCallback = useCallback( + (({ tooltipRef, getTooltipProps }) => { + return ( +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
{renderHeader(tooltipData[0])}
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + })} +
+ )} +
+ ); + }) as TooltipTriggerProps['tooltip'], + [tooltipData] + ); - const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; - const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; + const isTooltipShown = tooltipData.length > 0; return ( -
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
- {renderHeader(tooltipData[0], tooltipHeaderFormatter)} -
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} -
- )} -
+ + {triggerCallback} + + ); +}); + +interface MlTooltipComponentProps { + children: (tooltipService: ChartTooltipService) => React.ReactElement; +} + +export const MlTooltipComponent: FC = ({ children }) => { + const service = useMemo(() => new ChartTooltipService(), []); + + return ( + <> + + {children(service)} + ); }; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts deleted file mode 100644 index e6b0b6b4270bda8..000000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; - -export declare const getChartTooltipDefaultState: () => ChartTooltipState; - -export interface ChartTooltipValue extends TooltipValue { - skipHeader?: boolean; -} - -interface ChartTooltipState { - isTooltipVisible: boolean; - offset: ToolTipOffset; - targetPosition: ClientRect; - tooltipData: ChartTooltipValue[]; - tooltipHeaderFormatter?: TooltipValueFormatter; - tooltipPosition: { left: number; top: number }; -} - -export declare const chartTooltip$: BehaviorSubject; - -interface ToolTipOffset { - x: number; - y: number; -} - -interface MlChartTooltipService { - show: ( - tooltipData: ChartTooltipValue[], - target?: HTMLElement | null, - offset?: ToolTipOffset - ) => void; - hide: () => void; -} - -export declare const mlChartTooltipService: MlChartTooltipService; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js deleted file mode 100644 index 59cf98e5ffd71eb..000000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -export const getChartTooltipDefaultState = () => ({ - isTooltipVisible: false, - tooltipData: [], - offset: { x: 0, y: 0 }, - targetPosition: { left: 0, top: 0 }, - tooltipPosition: { left: 0, top: 0 }, -}); - -export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); - -export const mlChartTooltipService = { - show: (tooltipData, target, offset = { x: 0, y: 0 }) => { - if (typeof target !== 'undefined' && target !== null) { - chartTooltip$.next({ - ...chartTooltip$.getValue(), - isTooltipVisible: true, - offset, - targetPosition: target.getBoundingClientRect(), - tooltipData, - }); - } - }, - hide: () => { - chartTooltip$.next({ - ...getChartTooltipDefaultState(), - isTooltipVisible: false, - }); - }, -}; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts index aa1dbf92b0677a3..231854cd264c272 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,18 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; +import { + ChartTooltipService, + getChartTooltipDefaultState, + TooltipData, +} from './chart_tooltip_service'; -describe('ML - mlChartTooltipService', () => { - it('service API duck typing', () => { - expect(typeof mlChartTooltipService).toBe('object'); - expect(typeof mlChartTooltipService.show).toBe('function'); - expect(typeof mlChartTooltipService.hide).toBe('function'); +describe('ChartTooltipService', () => { + let service: ChartTooltipService; + + beforeEach(() => { + service = new ChartTooltipService(); + }); + + test('should update the tooltip state on show and hide', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + const update = [ + { + label: 'new tooltip', + }, + ] as TooltipData; + const mockEl = document.createElement('div'); + + service.show(update, mockEl); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: true, + tooltipData: update, + offset: { x: 0, y: 0 }, + target: mockEl, + }); + + service.hide(); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, + }); }); - it('should fail silently when target is not defined', () => { - expect(() => { - mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); - }).not.toThrow('Call to show() should fail silently.'); + test('update the tooltip state only on a new value', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + service.hide(); + + expect(spy).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts new file mode 100644 index 000000000000000..b524e18102a9522 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject, Observable } from 'rxjs'; +import { isEqual } from 'lodash'; +import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; +import { distinctUntilChanged } from 'rxjs/operators'; + +export interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +export interface TooltipHeader { + skipHeader: boolean; +} + +export type TooltipData = ChartTooltipValue[]; + +export interface ChartTooltipState { + isTooltipVisible: boolean; + offset: TooltipOffset; + tooltipData: TooltipData; + tooltipHeaderFormatter?: TooltipValueFormatter; + target: HTMLElement | null; +} + +interface TooltipOffset { + x: number; + y: number; +} + +export const getChartTooltipDefaultState = (): ChartTooltipState => ({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, +}); + +export class ChartTooltipService { + private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); + + public tooltipState$: Observable = this.chartTooltip$ + .asObservable() + .pipe(distinctUntilChanged(isEqual)); + + public show( + tooltipData: TooltipData, + target: HTMLElement, + offset: TooltipOffset = { x: 0, y: 0 } + ) { + if (!target) { + throw new Error('target is required for the tooltip positioning'); + } + + this.chartTooltip$.next({ + ...this.chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + tooltipData, + target, + }); + } + + public hide() { + this.chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false, + }); + } +} diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts index 75c65ebaa0f507a..ec19fe18bd3241d 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { mlChartTooltipService } from './chart_tooltip_service'; -export { ChartTooltip } from './chart_tooltip'; +export { ChartTooltipService } from './chart_tooltip_service'; +export { MlTooltipComponent } from './chart_tooltip'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index 381e5e75356c1b8..f709c161bef17ef 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,45 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useMlKibana } from '../../contexts/kibana'; import { Dictionary } from '../../../../common/types/common'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; import { useUrlState } from '../../util/url_state'; // @ts-ignore -import { JobSelectorTable } from './job_selector_table/index'; -// @ts-ignore import { IdBadges } from './id_badges/index'; -// @ts-ignore -import { NewSelectionIdBadges } from './new_selection_id_badges/index'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; +import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; interface GroupObj { groupId: string; jobIds: string[]; } + function mergeSelection( jobIds: string[], groupObjs: GroupObj[], @@ -71,7 +49,7 @@ function mergeSelection( } type GroupsMap = Dictionary; -function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { +export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { const map: GroupsMap = {}; if (selectedGroups.length) { @@ -83,81 +61,38 @@ function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } -const BADGE_LIMIT = 10; -const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; } +export interface JobSelectionMaps { + jobsMap: Dictionary; + groupsMap: Dictionary; +} + export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { const [globalState, setGlobalState] = useUrlState('_g'); const selectedJobIds = globalState?.ml?.jobIds ?? []; const selectedGroups = globalState?.ml?.groups ?? []; - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); + const [maps, setMaps] = useState({ + groupsMap: getInitialGroupsMap(selectedGroups), + jobsMap: {}, + }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); - const [newSelection, setNewSelection] = useState( - mergeSelection(selectedJobIds, selectedGroups, singleSelection) - ); - const [showAllBadges, setShowAllBadges] = useState(false); const [showAllBarBadges, setShowAllBarBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - const { - services: { notifications }, - } = useMlKibana(); // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); }, [JSON.stringify([selectedJobIds, selectedGroups])]); - // Ensure current selected ids always show up in flyout - useEffect(() => { - setNewSelection(selectedIds); - }, [isFlyoutVisible]); // eslint-disable-line - - // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. - // Not wrapping it would cause this dependency to change on every render - const handleResize = useCallback(() => { - if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - // get all cols in flyout table - const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( - 'table thead th' - ); - // get the width of the last col - const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; - const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); - setJobs(normalizedJobs); - const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); - setGroups(updatedGroups); - setGanttBarWidth(derivedWidth); - } - }, [dateFormatTz, jobs]); - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - function closeFlyout() { setIsFlyoutVisible(false); } @@ -168,78 +103,26 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function handleJobSelectionClick() { showFlyout(); - - ml.jobs - .jobsWithTimerange(dateFormatTz) - .then(resp => { - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setMaps({ groupsMap, jobsMap: resp.jobsMap }); - }) - .catch((err: any) => { - console.error('Error fetching jobs with time range', err); // eslint-disable-line - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { - defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', - }), - }); - }); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); } - function applySelection() { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; - - newSelection.forEach(id => { - if (maps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...maps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - + const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({ + newSelection, + jobIds, + groups: newGroups, + time, + }) => { setSelectedIds(newSelection); - setNewSelection([]); - - closeFlyout(); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; setGlobalState({ ml: { - jobIds: allNewSelectionUnique, - groups: groupSelection, + jobIds, + groups: newGroups, }, ...(time !== undefined ? { time } : {}), }); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - function clearSelection() { - setNewSelection([]); - } + closeFlyout(); + }; function renderJobSelectionBar() { return ( @@ -280,103 +163,16 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function renderFlyout() { if (isFlyoutVisible) { return ( - - - -

- {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { - defaultMessage: 'Job selection', - })} -

-
-
- - - - - setShowAllBadges(!showAllBadges)} - showAllBadges={showAllBadges} - /> - - - - - - {!singleSelection && newSelection.length > 0 && ( - - {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { - defaultMessage: 'Clear all', - })} - - )} - - - - - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
+ ); } } @@ -388,9 +184,3 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
); } - -JobSelector.propTypes = { - selectedJobIds: PropTypes.array, - singleSelection: PropTypes.bool, - timeseriesOnly: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx similarity index 68% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx index 4d2ab01e2a05442..b2cae278c0e77a2 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx @@ -4,18 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; -import { EuiBadge } from '@elastic/eui'; -import { tabColor } from '../../../../../common/util/group_color_utils'; +import React, { FC } from 'react'; +import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { tabColor } from '../../../../../common/util/group_color_utils'; -export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { +interface JobSelectorBadgeProps { + icon?: boolean; + id: string; + isGroup?: boolean; + numJobs?: number; + removeId?: Function; +} + +export const JobSelectorBadge: FC = ({ + icon, + id, + isGroup = false, + numJobs, + removeId, +}) => { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color }; + let props = { color } as EuiBadgeProps; let jobCount; - if (icon === true) { + if (icon === true && removeId) { + // @ts-ignore props = { ...props, iconType: 'cross', @@ -37,11 +51,4 @@ export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId {`${id}${jobCount ? jobCount : ''}`} ); -} -JobSelectorBadge.propTypes = { - icon: PropTypes.bool, - id: PropTypes.string.isRequired, - isGroup: PropTypes.bool, - numJobs: PropTypes.number, - removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx new file mode 100644 index 000000000000000..66aa05d2aaa975a --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -0,0 +1,289 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { NewSelectionIdBadges } from './new_selection_id_badges'; +// @ts-ignore +import { JobSelectorTable } from './job_selector_table'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { ml } from '../../services/ml_api_service'; +import { useMlKibana } from '../../contexts/kibana'; +import { JobSelectionMaps } from './job_selector'; + +export const BADGE_LIMIT = 10; +export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels + +export interface JobSelectorFlyoutProps { + dateFormatTz: string; + selectedIds?: string[]; + newSelection?: string[]; + onFlyoutClose: () => void; + onJobsFetched?: (maps: JobSelectionMaps) => void; + onSelectionChange?: (newSelection: string[]) => void; + onSelectionConfirmed: (payload: { + newSelection: string[]; + jobIds: string[]; + groups: Array<{ groupId: string; jobIds: string[] }>; + time: any; + }) => void; + singleSelection: boolean; + timeseriesOnly: boolean; + maps: JobSelectionMaps; + withTimeRangeSelector?: boolean; +} + +export const JobSelectorFlyout: FC = ({ + dateFormatTz, + selectedIds = [], + singleSelection, + timeseriesOnly, + onJobsFetched, + onSelectionChange, + onSelectionConfirmed, + onFlyoutClose, + maps, + withTimeRangeSelector = true, +}) => { + const { + services: { notifications }, + } = useMlKibana(); + + const [newSelection, setNewSelection] = useState(selectedIds); + + const [showAllBadges, setShowAllBadges] = useState(false); + const [applyTimeRange, setApplyTimeRange] = useState(true); + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); + const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); + + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + + function applySelection() { + // allNewSelection will be a list of all job ids (including those from groups) selected from the table + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + + newSelection.forEach(id => { + if (jobGroupsMaps.groupsMap[id] !== undefined) { + // Push all jobs from selected groups into the newSelection list + allNewSelection.push(...jobGroupsMaps.groupsMap[id]); + // if it's a group - push group obj to set in global state + groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); + } else { + allNewSelection.push(id); + } + }); + // create a Set to remove duplicate values + const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; + + onSelectionConfirmed({ + newSelection: allNewSelectionUnique, + jobIds: allNewSelectionUnique, + groups: groupSelection, + time, + }); + } + + function removeId(id: string) { + setNewSelection(newSelection.filter(item => item !== id)); + } + + function toggleTimerangeSwitch() { + setApplyTimeRange(!applyTimeRange); + } + + function clearSelection() { + setNewSelection([]); + } + + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { + setNewSelection(selectionFromTable); + } + + // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. + // Not wrapping it would cause this dependency to change on every render + const handleResize = useCallback(() => { + if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { + // get all cols in flyout table + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); + // get the width of the last col + const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; + const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); + setJobs(normalizedJobs); + const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); + setGroups(updatedGroups); + setGanttBarWidth(derivedWidth); + } + }, [dateFormatTz, jobs]); + + // Fetch jobs list on flyout open + useEffect(() => { + fetchJobs(); + }, []); + + async function fetchJobs() { + try { + const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); + const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); + const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); + setJobs(normalizedJobs); + setGroups(groupsWithTimerange); + setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); + + if (onJobsFetched) { + onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); + } + } catch (e) { + console.error('Error fetching jobs with time range', e); // eslint-disable-line + const { toasts } = notifications; + toasts.addDanger({ + title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { + defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', + }), + }); + } + } + + useEffect(() => { + // Ensure ganttBar width gets calculated on resize + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + useEffect(() => { + handleResize(); + }, [handleResize, jobs]); + + return ( + + + +

+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { + defaultMessage: 'Job selection', + })} +

+
+
+ + + + + setShowAllBadges(!showAllBadges)} + showAllBadges={showAllBadges} + /> + + + + + + {!singleSelection && newSelection.length > 0 && ( + + {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { + defaultMessage: 'Clear all', + })} + + )} + + {withTimeRangeSelector && ( + + + + )} + + + + + + + + + + {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { + defaultMessage: 'Apply', + })} + + + + + {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 64793d15f1e4a4f..c55e03776c09d8e 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && singleSelection === undefined && renderTabs()} + {jobs.length !== 0 && !singleSelection && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index 67dce47323889e9..4c018e72f3e10ca 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -4,20 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; +import React, { FC, MouseEventHandler } from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { JobSelectorBadge } from '../job_selector_badge'; import { i18n } from '@kbn/i18n'; +import { JobSelectorBadge } from '../job_selector_badge'; +import { JobSelectionMaps } from '../job_selector'; -export function NewSelectionIdBadges({ +interface NewSelectionIdBadgesProps { + limit: number; + maps: JobSelectionMaps; + newSelection: string[]; + onDeleteClick?: Function; + onLinkClick?: MouseEventHandler; + showAllBadges?: boolean; +} + +export const NewSelectionIdBadges: FC = ({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) { +}) => { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -60,16 +69,5 @@ export function NewSelectionIdBadges({ ); } - return badges; -} -NewSelectionIdBadges.propTypes = { - limit: PropTypes.number, - maps: PropTypes.shape({ - jobsMap: PropTypes.object, - groupsMap: PropTypes.object, - }), - newSelection: PropTypes.array, - onDeleteClick: PropTypes.func, - onLinkClick: PropTypes.func, - showAllBadges: PropTypes.bool, + return <>{badges}; }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 86ffc4a2614b9c8..06d89ab782167ba 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = new TimeBuckets(); + const buckets = getTimeBucketsFromCache(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 9fb2f0c3bed9443..cfcba081983c2cc 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,164 +106,6 @@ padding: 0; margin-bottom: $euiSizeS; - div.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } - } - line.gridLine { stroke: $euiBorderColor; fill: none; @@ -328,3 +170,161 @@ } } } + +.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index d61d56d07b6446e..86d16776b68e2ae 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,9 +36,8 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; -import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { TimeBuckets } from '../util/time_buckets'; +import { getTimeBucketsFromCache } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -81,6 +80,7 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; +import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,6 +179,8 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); + + this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -358,9 +360,6 @@ export class Explorer extends React.Component { return (
- {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} - - {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - + + {tooltipService => ( + + )} + )}
@@ -494,17 +498,22 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - + + {tooltipService => ( + + )} +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 5fc1160093a491d..03426869b0ccfc8 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,9 +29,8 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -50,6 +49,7 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 71d777db5b2ec62..06fd82204c1e1a7 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index dd9479be931a79f..82041af39ca15e6 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,10 +38,9 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -53,6 +52,7 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index ca3e52308a9366c..54f541ceb7c3da9 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 99de38c1e0a84a6..5b95931d31ab618 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import $ from 'jquery'; - import React from 'react'; import { @@ -29,6 +27,7 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -121,19 +120,29 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - + + {tooltipService => ( + + )} + ); } return ( - + + {tooltipService => ( + + )} + ); })()} @@ -141,48 +150,36 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export class ExplorerChartsContainer extends React.Component { - componentDidMount() { - // Create a div for the tooltip. - $('.ml-explorer-charts-tooltip').remove(); - $('body').append( - '